diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..a5e2dfc --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,11 @@ +name: Build ZMK firmware + +on: [push, pull_request, workflow_dispatch] + +jobs: + build: + # Pinned to v0.3.0 so the workflow's toolchain matches the manifest in + # zmk-config/west.yml. Do not switch to @main without also moving the manifest. + uses: zmkfirmware/zmk/.github/workflows/build-user-config.yml@v0.3.0 + with: + config_path: zmk-config diff --git a/.gitignore b/.gitignore index fc267f5..e669a0e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,16 @@ hardware/kicad_5.99/.xdp-anaphase.pro-tgFd8n zmk-config/boards/arm/anaphase/widgets/temp .vscode/settings.json + +# macOS / KiCad noise +.DS_Store +hardware/kicad_5.99/*.lck + +# west workspace (created by `west init -l zmk-config` for local builds) +.west/ +zmk/ +zephyr/ +modules/ +tools/ +bootloader/ +build/ diff --git a/README.md b/README.md index 4917073..8f8bc6e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,32 @@ # anaphase wireless split keyboard anaphase is a Bluetooth/USB split ergonomic keyboard with a 3D printed case and custom PCB base. + +## Firmware + +ZMK config lives in `zmk-config/`, defining `anaphase_left` (BLE central) and `anaphase_right` +as full boards (E73-2G4M08S1C nRF52840 modules, not a shield). Pinned to **ZMK v0.3.0** +(Zephyr 3.5, LVGL 8.3.7). CI builds both halves via the shared ZMK user-config workflow; +grab `anaphase_left-zmk.uf2` / `anaphase_right-zmk.uf2` from the Actions artifacts and +drag onto the Adafruit bootloader mass-storage device (double-tap reset). + +### Rollback (last pre-migration build, 2022-03-05) + +**Primary: `archive/2022 dumps/CURRENT_{left,right}.UF2`** — bootloader readbacks of the +actual firmware both halves ran for four years, captured 2026-08-09 before the v0.3.0 +migration was flashed. Verified: contiguous 0x1000..0xEA000, valid vector tables, includes +the NVS storage region (BLE bonds as of capture). To restore: double-tap reset on the +affected half, drag the matching file onto the mass-storage volume. The family ID differs +from newly-built uf2s (0x239A0029 board-specific vs 0xADA52840 generic) — bootloader 0.6.2 +accepts both, verified against its source. + +Secondary (reconstruction from source, if the dumps are ever lost): + +- zmk-config: commit `9e7d2f7` +- zmkfirmware/zmk: commit `f1b5dc408144e7f334bad59da6e9ac788a56e0c7` (main @ 2022-03-05) +- which pins zephyr `v2.5.0+zmk-fixes` (LVGL 7.6.1) +- build container: `zmkfirmware/zmk-build-arm:2.5` (local Docker build; modern CI images + will not build this tree) + +Bootloader on both halves: Adafruit nRF52 UF2 bootloader 0.6.2, pca10056 build +(`archive/2022 dumps/INFO_UF2_*.TXT`). App flashing never touches it. diff --git a/archive/2022 dumps/CURRENT_left.UF2 b/archive/2022 dumps/CURRENT_left.UF2 new file mode 100755 index 0000000..b384802 Binary files /dev/null and b/archive/2022 dumps/CURRENT_left.UF2 differ diff --git a/archive/2022 dumps/CURRENT_right.UF2 b/archive/2022 dumps/CURRENT_right.UF2 new file mode 100755 index 0000000..1a95a67 Binary files /dev/null and b/archive/2022 dumps/CURRENT_right.UF2 differ diff --git a/archive/2022 dumps/INDEX_left.HTM b/archive/2022 dumps/INDEX_left.HTM new file mode 100755 index 0000000..5c5edff --- /dev/null +++ b/archive/2022 dumps/INDEX_left.HTM @@ -0,0 +1,4 @@ + + diff --git a/archive/2022 dumps/INDEX_right.HTM b/archive/2022 dumps/INDEX_right.HTM new file mode 100755 index 0000000..5c5edff --- /dev/null +++ b/archive/2022 dumps/INDEX_right.HTM @@ -0,0 +1,4 @@ + + diff --git a/archive/2022 dumps/INFO_UF2_left.TXT b/archive/2022 dumps/INFO_UF2_left.TXT new file mode 100755 index 0000000..7100c2a --- /dev/null +++ b/archive/2022 dumps/INFO_UF2_left.TXT @@ -0,0 +1,5 @@ +UF2 Bootloader 0.6.2 lib/nrfx (v2.0.0) lib/tinyusb (0.10.1-293-gaf8e5a90) lib/uf2 (remotes/origin/configupdate-9-gadbb8c7) +Model: Nordic nRF52840 DK +Board-ID: nRF52840-pca10056-v1 +SoftDevice: not found +Date: Sep 10 2021 diff --git a/archive/2022 dumps/INFO_UF2_right.TXT b/archive/2022 dumps/INFO_UF2_right.TXT new file mode 100755 index 0000000..7100c2a --- /dev/null +++ b/archive/2022 dumps/INFO_UF2_right.TXT @@ -0,0 +1,5 @@ +UF2 Bootloader 0.6.2 lib/nrfx (v2.0.0) lib/tinyusb (0.10.1-293-gaf8e5a90) lib/uf2 (remotes/origin/configupdate-9-gadbb8c7) +Model: Nordic nRF52840 DK +Board-ID: nRF52840-pca10056-v1 +SoftDevice: not found +Date: Sep 10 2021 diff --git a/build.yaml b/build.yaml new file mode 100644 index 0000000..3756737 --- /dev/null +++ b/build.yaml @@ -0,0 +1,5 @@ +# Build matrix for the shared ZMK user-config workflow. +# Board IDs = the *_defconfig basenames under zmk-config/boards/arm/anaphase/. +include: + - board: anaphase_left + - board: anaphase_right diff --git a/zmk-config/boards/arm/anaphase/CMakeLists.txt b/zmk-config/boards/arm/anaphase/CMakeLists.txt index 6027c64..43ee667 100644 --- a/zmk-config/boards/arm/anaphase/CMakeLists.txt +++ b/zmk-config/boards/arm/anaphase/CMakeLists.txt @@ -1,28 +1,18 @@ # SPDX-License-Identifier: MIT -zephyr_library() -zephyr_library_sources(board.c) -zephyr_library_include_directories(${ZEPHYR_BASE}/drivers) - -# -b argument must match &code_partition address in numble.dts -set_property(GLOBAL APPEND PROPERTY extra_post_build_commands - COMMAND ${PYTHON_EXECUTABLE} ${ZEPHYR_BASE}/../tools/uf2/utils/uf2conv.py - -c - -b 0x1000 - -f 0xADA52840 - -o ${PROJECT_BINARY_DIR}/${CONFIG_KERNEL_BIN_NAME}.uf2 - ${PROJECT_BINARY_DIR}/${CONFIG_KERNEL_BIN_NAME}.bin -) - -target_sources_ifdef(CONFIG_CUSTOM_WIDGET_BATTERY_STATUS app PRIVATE widgets/battery_status.c) -target_sources_ifdef(CONFIG_CUSTOM_WIDGET_OUTPUT_STATUS app PRIVATE widgets/output_status.c) -target_sources_ifdef(CONFIG_CUSTOM_WIDGET_LAYER_STATUS app PRIVATE widgets/layer_status.c) -target_sources_ifdef(CONFIG_CUSTOM_WIDGET_LAYER_STATUS app PRIVATE widgets/wpm_status.c) +# UF2 output is now produced natively via CONFIG_BUILD_OUTPUT_UF2 (family ID +# and base address come from SOC_NRF52840_QIAA + the code_partition chosen), +# replacing the old hand-rolled uf2conv.py post-build hook. +# DC/DC setup moved from board.c to Kconfig (SOC_DCDC_NRF52X_HV). +if(CONFIG_ZMK_DISPLAY) + target_sources_ifdef(CONFIG_CUSTOM_WIDGET_BATTERY_STATUS app PRIVATE widgets/battery_status.c) + target_sources_ifdef(CONFIG_CUSTOM_WIDGET_OUTPUT_STATUS app PRIVATE widgets/output_status.c) + target_sources_ifdef(CONFIG_CUSTOM_WIDGET_LAYER_STATUS app PRIVATE widgets/layer_status.c) +endif() +zephyr_library() zephyr_library_include_directories(${ZEPHYR_LVGL_MODULE_DIR}) -zephyr_library_include_directories(${ZEPHYR_BASE}/lib/gui/lvgl/) zephyr_library_sources_ifdef(CONFIG_ZMK_DISPLAY custom_status_screen.c) zephyr_library_sources(${ZEPHYR_BASE}/misc/empty_file.c) zephyr_library_include_directories(${CMAKE_SOURCE_DIR}/include) -zephyr_library_include_directories(${ZEPHYR_BASE}/drivers) \ No newline at end of file diff --git a/zmk-config/boards/arm/anaphase/Kconfig b/zmk-config/boards/arm/anaphase/Kconfig index ed37bc6..6c67882 100644 --- a/zmk-config/boards/arm/anaphase/Kconfig +++ b/zmk-config/boards/arm/anaphase/Kconfig @@ -1,7 +1,21 @@ # SPDX-License-Identifier: MIT +# Main (REG1) DC/DC converter. Deliberately default n: the original 2021 +# config never enabled this (the old `depends on BOARD_ANAPHASE` referenced a +# nonexistent symbol, so it was unselectable), the shipped firmware was +# power-validated in that state, and the E73 module does not bond out +# DCC/DEC4 so buck-mode support for this stage is unverified. config BOARD_ENABLE_DCDC bool "Enable DCDC mode" select SOC_DCDC_NRF52X + default n + depends on (BOARD_ANAPHASE_LEFT || BOARD_ANAPHASE_RIGHT) + +# High-voltage (REG0) DC/DC converter, LiPo -> VDDH -> 1V8. Default y: +# this replaces the removed board.c, which force-enabled it on every boot +# since 2021. The DCCH inductor (L1/L2, 10uH) is fitted on the PCB. +config BOARD_ENABLE_DCDC_HV + bool "High voltage DCDC converter" + select SOC_DCDC_NRF52X_HV default y - depends on BOARD_ANAPHASE \ No newline at end of file + depends on (BOARD_ANAPHASE_LEFT || BOARD_ANAPHASE_RIGHT) diff --git a/zmk-config/boards/arm/anaphase/Kconfig.defconfig b/zmk-config/boards/arm/anaphase/Kconfig.defconfig index 26795a0..6a169be 100644 --- a/zmk-config/boards/arm/anaphase/Kconfig.defconfig +++ b/zmk-config/boards/arm/anaphase/Kconfig.defconfig @@ -5,6 +5,17 @@ if BOARD_ANAPHASE_LEFT config ZMK_KEYBOARD_NAME default "anaphase" +# Left half is the BLE central. (Old-name alias ZMK_SPLIT_BLE_ROLE_CENTRAL +# still exists at v0.3.0 but new configs should set this symbol.) +config ZMK_SPLIT_ROLE_CENTRAL + default y + +# USB HID output is central-only at v0.3.0 (ZMK_USB now depends on +# ZMK_SPLIT_ROLE_CENTRAL). The right half keeps the bare USB device stack +# for charge detection — see anaphase_right_defconfig. +config ZMK_USB + default y + endif # BOARD_ANAPHASE_LEFT if BOARD_ANAPHASE_RIGHT @@ -20,7 +31,7 @@ config BOARD default "anaphase" config ZMK_SPLIT - default y + default y if USB @@ -38,31 +49,27 @@ config BT_CTLR config ZMK_BLE default y -config ZMK_USB - default y - +# Battery is sensed on VDDH (LiPo direct to VDDH, SAADC internal VDDHDIV5). config ZMK_BATTERY_NRF_VDDH default y config ZMK_DISPLAY - select LVGL_FONT_UNSCII_8 - select LVGL_USE_LABEL - select ZMK_WPM + select LV_FONT_UNSCII_8 + select LV_USE_LABEL choice ZMK_DISPLAY_STATUS_SCREEN - default ZMK_DISPLAY_STATUS_SCREEN_CUSTOM + default ZMK_DISPLAY_STATUS_SCREEN_CUSTOM endchoice - -if ZMK_DISPLAY -choice LVGL_THEME_DEFAULT_FONT_NORMAL - default LVGL_THEME_DEFAULT_FONT_NORMAL_UNSCII_8 - -endchoice +if ZMK_DISPLAY -choice LVGL_THEME_DEFAULT_FONT_SMALL - default LVGL_THEME_DEFAULT_FONT_SMALL_UNSCII_8 +# UNSCII-8 everywhere: the 96x48 text layout only fits with this font. +# (ZMK's own default is Montserrat, which does not.) +# The theme "small" font counterpart is set in the *_defconfig files — +# a choice-default override here loses to ZMK's own default. +choice LV_FONT_DEFAULT + default LV_FONT_DEFAULT_UNSCII_8 endchoice @@ -72,38 +79,31 @@ config SPI config SSD1306 default y -config LVGL_HOR_RES_MAX - default 96 - -config LVGL_VER_RES_MAX - default 48 - -config LVGL_VDB_SIZE +# Panel resolution now comes from the width/height DT properties on the +# ssd1306 node (the old LVGL_HOR_RES_MAX/VER_RES_MAX symbols are gone). +config LV_Z_VDB_SIZE default 100 -config LVGL_DPI +config LV_DPI_DEF default 50 -config LVGL_BITS_PER_PIXEL +config LV_Z_BITS_PER_PIXEL default 1 -choice LVGL_COLOR_DEPTH - default LVGL_COLOR_DEPTH_1 +choice LV_COLOR_DEPTH + default LV_COLOR_DEPTH_1 endchoice endif # ZMK_DISPLAY -menuconfig CUSTOM_WIDGET_BATTERY_STATUS - bool "custom battery status widget" - -menuconfig CUSTOM_WIDGET_OUTPUT_STATUS - bool "custom output status widget" +config CUSTOM_WIDGET_BATTERY_STATUS + bool "custom battery status widget" -menuconfig CUSTOM_WIDGET_LAYER_STATUS - bool "custom layer status widget" +config CUSTOM_WIDGET_OUTPUT_STATUS + bool "custom output status widget" -menuconfig CUSTOM_WIDGET_WPM_STATUS - bool "custom wpm status widget" +config CUSTOM_WIDGET_LAYER_STATUS + bool "custom layer status widget" -endif # BOARD_ANAPHASE_LEFT || BOARD_ANAPHASE_RIGHT \ No newline at end of file +endif # BOARD_ANAPHASE_LEFT || BOARD_ANAPHASE_RIGHT diff --git a/zmk-config/boards/arm/anaphase/anaphase-pinctrl.dtsi b/zmk-config/boards/arm/anaphase/anaphase-pinctrl.dtsi new file mode 100644 index 0000000..13b1fab --- /dev/null +++ b/zmk-config/boards/arm/anaphase/anaphase-pinctrl.dtsi @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2021 + * + * SPDX-License-Identifier: MIT + */ + +&pinctrl { + /* + * OLED SPI. Write-only bus: the SSD1306 has no MISO, so no + * SPIM_MISO psel is assigned (the pre-Zephyr-3.0 config sacrificed + * P0.11 to a dummy miso-pin the old binding required). + * CS (P0.07) is driven as a GPIO via cs-gpios on &spi0, and + * D/C (P0.04) / RESET (P0.12) belong to the display node. + */ + spi0_default: spi0_default { + group1 { + psels = , + ; + }; + }; + + spi0_sleep: spi0_sleep { + group1 { + psels = , + ; + low-power-enable; + }; + }; +}; diff --git a/zmk-config/boards/arm/anaphase/anaphase.dtsi b/zmk-config/boards/arm/anaphase/anaphase.dtsi index c6ddc65..7c562d9 100644 --- a/zmk-config/boards/arm/anaphase/anaphase.dtsi +++ b/zmk-config/boards/arm/anaphase/anaphase.dtsi @@ -7,6 +7,7 @@ /dts-v1/; #include #include +#include "anaphase-pinctrl.dtsi" / { model = "anaphase"; @@ -16,11 +17,19 @@ zephyr,code-partition = &code_partition; zephyr,sram = &sram0; zephyr,flash = &flash0; + // ZMK >= mid-2022 finds the panel via this chosen node; before that it + // was looked up by device name ("DISPLAY"). Without this line the + // display subsystem fails to compile. + zephyr,display = &oled; zmk,kscan = &kscan0; zmk,matrix_transform = &transform; zmk,battery = &vbatt; }; #define K(n) RC(0,n) + // columns/rows here describe the *keymap grid*, not the kscan (which is + // direct-wired, 1 row x 42 "columns"). This works because direct kscan + // always reports row 0, so the lookup index (row*columns)+col == col. + // Load-bearing coincidence — leave as is. transform: transform { compatible = "zmk,matrix-transform"; columns = <12>; @@ -35,29 +44,36 @@ vbatt: vbatt { compatible = "zmk,battery-nrf-vddh"; - label = "BATTERY"; }; }; &spi0 { compatible = "nordic,nrf-spim"; status = "okay"; - sck-pin = <8>; - mosi-pin = <6>; + pinctrl-0 = <&spi0_default>; + pinctrl-1 = <&spi0_sleep>; + pinctrl-names = "default", "sleep"; cs-gpios = <&gpio0 7 GPIO_ACTIVE_LOW>; - clock-frequency = <8000000>; - miso-pull-down; - // unused pin, but needed by SPI definition - miso-pin = <11>; oled: ssd1306@0 { compatible = "solomon,ssd1306fb"; reg = <0>; - label = "DISPLAY"; data_cmd-gpios = <&gpio0 4 GPIO_ACTIVE_HIGH>; reset-gpios = <&gpio0 12 GPIO_ACTIVE_LOW>; spi-max-frequency = <8000000>; - width = <96>; + // Non-standard panel, 64x48 *visible*: multiplex-ratio/prechargep/ + // com-invdir/segment-remap below were tuned by trial in 2021 + // (commits 9f465ef..d633f66). Do not "normalise" to 128x64 values. + // + // width is deliberately 128 = the full controller RAM, of which the + // glass shows a 64-column window covering (roughly) the framebuffer + // range x=32..95 — which is why every widget is anchored at x=32. + // The 2021 value of 96 left RAM columns >=96 unwritten, and the + // window edge shows ~4 of them: power-on garbage, invisible for four + // years under the dark theme, exposed as a lit 4px bar by the LVGL 8 + // migration. Rendering all 128 columns keeps every column the window + // can reach painted. Content position on glass is unchanged. + width = <128>; height = <48>; segment-offset = <0>; page-offset = <0>; diff --git a/zmk-config/boards/arm/anaphase/anaphase.keymap b/zmk-config/boards/arm/anaphase/anaphase.keymap index 5a9db28..982a5b0 100644 --- a/zmk-config/boards/arm/anaphase/anaphase.keymap +++ b/zmk-config/boards/arm/anaphase/anaphase.keymap @@ -52,7 +52,7 @@ compatible = "zmk,keymap"; BASE { - label = "BASE"; + display-name = "BASE"; bindings = < // 1 2 3 4 5 6 7 8 9 10 11 12 &mt LSHFT ESC &kp Q &kp P &kp Y &kp C &kp B &kp V &kp M &kp U &kp Z &kp L &kp DEL @@ -64,7 +64,7 @@ }; SHIFT { - label = "SHIFT"; + display-name = "SHIFT"; bindings = < // 1 2 3 4 5 6 7 8 9 10 11 12 &kp LS(ESC) &kp LS(Q) &kp LS(P) &kp LS(Y) &kp LS(C) &kp LS(B) &kp LS(V) &kp LS(M) &kp LS(U) &kp LS(Z) &kp LS(L) &kp LS(DEL) @@ -76,7 +76,7 @@ }; FUNC { - label = "FUNC"; + display-name = "FUNC"; bindings = < // 1 2 3 4 5 6 7 8 9 10 11 12 &kp TILDE &kp AMPS &kp PRCNT &kp UP &kp CARET &kp PIPE &kp LBKT &kp N7 &kp N8 &kp N9 &kp MINUS &kp PG_UP @@ -88,7 +88,7 @@ }; FNSHFT { - label = "FUNCSHFT"; + display-name = "FUNCSHFT"; bindings = < // 1 2 3 4 5 6 7 8 9 10 11 12 &kp GRAVE &none &kp PSCRN &kp LS(UP) &none &none &kp RBKT &kp F7 &kp F8 &kp F9 &kp F10 &kp HOME @@ -100,7 +100,7 @@ }; SYS { - label = "SYS"; + display-name = "SYS"; bindings = < //1 2 3 4 5 6 7 8 9 10 11 12 &bt BT_CLR &bt BT_SEL 0 &bt BT_SEL 1 &bt BT_SEL 2 &bt BT_SEL 3 &bt BT_SEL 4 &none &none &none &none &none &none diff --git a/zmk-config/boards/arm/anaphase/anaphase.zmk.yml b/zmk-config/boards/arm/anaphase/anaphase.zmk.yml new file mode 100644 index 0000000..236946c --- /dev/null +++ b/zmk-config/boards/arm/anaphase/anaphase.zmk.yml @@ -0,0 +1,15 @@ +file_format: "1" +id: anaphase +name: anaphase +url: https://github.com/reversebias/anaphase +type: board +arch: arm +features: + - keys + - display +outputs: + - usb + - ble +siblings: + - anaphase_left + - anaphase_right diff --git a/zmk-config/boards/arm/anaphase/anaphase_left.dts b/zmk-config/boards/arm/anaphase/anaphase_left.dts index 3b6fb85..713bd4a 100644 --- a/zmk-config/boards/arm/anaphase/anaphase_left.dts +++ b/zmk-config/boards/arm/anaphase/anaphase_left.dts @@ -9,7 +9,7 @@ /{ kscan0: kscan { compatible = "zmk,kscan-gpio-direct"; - label = "KSCAN"; + wakeup-source; input-gpios = <&gpio1 11 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)> diff --git a/zmk-config/boards/arm/anaphase/anaphase_left.yaml b/zmk-config/boards/arm/anaphase/anaphase_left.yaml new file mode 100644 index 0000000..d432685 --- /dev/null +++ b/zmk-config/boards/arm/anaphase/anaphase_left.yaml @@ -0,0 +1,15 @@ +identifier: anaphase_left +name: anaphase left +type: keyboard +arch: arm +toolchain: + - zephyr + - gnuarmemb +supported: + - gpio + - spi + - usb_device + - nvs + - kscan + - ble + - adc diff --git a/zmk-config/boards/arm/anaphase/anaphase_left_defconfig b/zmk-config/boards/arm/anaphase/anaphase_left_defconfig index 0e25983..06fa90a 100644 --- a/zmk-config/boards/arm/anaphase/anaphase_left_defconfig +++ b/zmk-config/boards/arm/anaphase/anaphase_left_defconfig @@ -3,8 +3,8 @@ CONFIG_SOC_SERIES_NRF52X=y CONFIG_SOC_NRF52840_QIAA=y CONFIG_BOARD_ANAPHASE_LEFT=y -CONFIG_ZMK_SPLIT=y -CONFIG_ZMK_SPLIT_BLE_ROLE_CENTRAL=y + +# Sleep: 60s idle (display blanks), 2h to deep sleep. Deliberate 2021 tune. CONFIG_ZMK_SLEEP=y CONFIG_ZMK_IDLE_TIMEOUT=60000 CONFIG_ZMK_IDLE_SLEEP_TIMEOUT=7200000 @@ -12,11 +12,15 @@ CONFIG_ZMK_IDLE_SLEEP_TIMEOUT=7200000 # Enable MPU CONFIG_ARM_MPU=y -# Enable GPIO +# Enable GPIO + pinctrl (SPI pins are defined in anaphase-pinctrl.dtsi) CONFIG_GPIO=y +CONFIG_PINCTRL=y -# Place application at &code_partition address +# Place application at &code_partition address, produce a .uf2 natively +# (replaces the old uf2conv.py post-build hook; family ID 0xada52840 and +# base 0x1000 both derive from SOC/DT automatically) CONFIG_USE_DT_CODE_PARTITION=y +CONFIG_BUILD_OUTPUT_UF2=y # Enable NVS for settings CONFIG_MPU_ALLOW_FLASH_WRITE=y @@ -26,27 +30,44 @@ CONFIG_FLASH=y CONFIG_FLASH_PAGE_LAYOUT=y CONFIG_FLASH_MAP=y -# nRF52840 can handle faster USB polling -CONFIG_USB_HID_POLL_INTERVAL_MS=1 +# (USB_HID_POLL_INTERVAL_MS=1 is now ZMK's own default under ZMK_USB) +# Display: dedicated work queue; stack raised 2048->4096 for LVGL 8, and the +# LVGL pool sized explicitly (a custom status screen gets no upstream default; +# the 2022 build ran on LVGL 7's implicit pool and had a rare, unreproduced +# crash consistent with display-path memory pressure). CONFIG_ZMK_DISPLAY=y CONFIG_ZMK_DISPLAY_WORK_QUEUE_DEDICATED=y -CONFIG_ZMK_DISPLAY_DEDICATED_THREAD_STACK_SIZE=2048 -CONFIG_LVGL_USE_THEME_MONO=y +CONFIG_ZMK_DISPLAY_DEDICATED_THREAD_STACK_SIZE=4096 +CONFIG_LV_Z_MEM_POOL_SIZE=8192 +CONFIG_LV_USE_THEME_MONO=y +# White-on-black like the 2022 build: LVGL 8's mono theme defaults to the +# light variant (LVGL 7 defaulted dark); this flips it back. +CONFIG_ZMK_DISPLAY_INVERT=y +# Theme small font: UNSCII-8 like everything else (default is Montserrat 12) +CONFIG_ZMK_LV_FONT_DEFAULT_SMALL_UNSCII_8=y -#CONFIG_ZMK_BLE_CLEAR_BONDS_ON_START=y -CONFIG_BT_MAX_CONN=6 -#CONFIG_BT_CTLR_TX_PWR_PLUS_8=y +# 32kHz source is the internal RC on purpose. Y1/Y2 crystals are fitted but +# their load caps were never populated; the crystal was unstable in testing +# (2021) and was deliberately abandoned. Switching to K32SRC_XTAL will hang +# the boot waiting for LFCLK. Needs a PCB rev to ever revisit. CONFIG_CLOCK_CONTROL_NRF_K32SRC_RC=y + CONFIG_BT_CTLR_TX_PWR_PLUS_8=y -CONFIG_BT_PERIPHERAL_PREF_MIN_INT=6 -CONFIG_BT_PERIPHERAL_PREF_MAX_INT=12 +# BT_MAX_CONN / BT_PERIPHERAL_PREF_* deliberately unset: ZMK v0.3.0's +# role-aware defaults are 6 conns on the central and 6/12/30/400 conn params +# (same 7.5-15ms interval as the old hand-tune, plus slave latency 30 for +# ~30x fewer idle radio wakeups at zero added press latency). +# Debounce tuned against the actual switches for minimum first-press latency +# (2ms press) with chatter protection on release (10ms). Asymmetry is +# intentional — do not normalise to the 5/5 default. Last tuned 2022-03-05. CONFIG_ZMK_KSCAN_DEBOUNCE_PRESS_MS=2 CONFIG_ZMK_KSCAN_DEBOUNCE_RELEASE_MS=10 + CONFIG_ZMK_HID_CONSUMER_REPORT_USAGES_BASIC=y -# custom status screens +# Custom status screen: BLE profile nickname + pairing status + battery + layer CONFIG_ZMK_DISPLAY_STATUS_SCREEN_CUSTOM=y CONFIG_CUSTOM_WIDGET_BATTERY_STATUS=y CONFIG_ZMK_WIDGET_BATTERY_STATUS=n @@ -54,11 +75,3 @@ CONFIG_CUSTOM_WIDGET_OUTPUT_STATUS=y CONFIG_ZMK_WIDGET_OUTPUT_STATUS=n CONFIG_CUSTOM_WIDGET_LAYER_STATUS=y CONFIG_ZMK_WIDGET_LAYER_STATUS=n -CONFIG_CUSTOM_WIDGET_WPM_STATUS=y -CONFIG_ZMK_WIDGET_WPM_STATUS=n - - -#USB debugging -#CONFIG_ZMK_USB_LOGGING=y -#CONFIG_ZMK_USB=n - diff --git a/zmk-config/boards/arm/anaphase/anaphase_right.dts b/zmk-config/boards/arm/anaphase/anaphase_right.dts index 81567a1..04374f5 100644 --- a/zmk-config/boards/arm/anaphase/anaphase_right.dts +++ b/zmk-config/boards/arm/anaphase/anaphase_right.dts @@ -9,7 +9,7 @@ /{ kscan0: kscan { compatible = "zmk,kscan-gpio-direct"; - label = "KSCAN"; + wakeup-source; input-gpios = <&gpio1 2 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)> diff --git a/zmk-config/boards/arm/anaphase/anaphase_right.yaml b/zmk-config/boards/arm/anaphase/anaphase_right.yaml new file mode 100644 index 0000000..1f5d437 --- /dev/null +++ b/zmk-config/boards/arm/anaphase/anaphase_right.yaml @@ -0,0 +1,15 @@ +identifier: anaphase_right +name: anaphase right +type: keyboard +arch: arm +toolchain: + - zephyr + - gnuarmemb +supported: + - gpio + - spi + - usb_device + - nvs + - kscan + - ble + - adc diff --git a/zmk-config/boards/arm/anaphase/anaphase_right_defconfig b/zmk-config/boards/arm/anaphase/anaphase_right_defconfig index f0fc906..8f78d73 100644 --- a/zmk-config/boards/arm/anaphase/anaphase_right_defconfig +++ b/zmk-config/boards/arm/anaphase/anaphase_right_defconfig @@ -3,8 +3,9 @@ CONFIG_SOC_SERIES_NRF52X=y CONFIG_SOC_NRF52840_QIAA=y CONFIG_BOARD_ANAPHASE_RIGHT=y -CONFIG_ZMK_SPLIT=y -#CONFIG_ZMK_SPLIT_BLE_ROLE_PERIPHERAL=y +# Peripheral role is implied by ZMK_SPLIT without ZMK_SPLIT_ROLE_CENTRAL. + +# Sleep: 60s idle (display blanks), 2h to deep sleep. Deliberate 2021 tune. CONFIG_ZMK_SLEEP=y CONFIG_ZMK_IDLE_TIMEOUT=60000 CONFIG_ZMK_IDLE_SLEEP_TIMEOUT=7200000 @@ -12,11 +13,13 @@ CONFIG_ZMK_IDLE_SLEEP_TIMEOUT=7200000 # Enable MPU CONFIG_ARM_MPU=y -# Enable GPIO +# Enable GPIO + pinctrl (SPI pins are defined in anaphase-pinctrl.dtsi) CONFIG_GPIO=y +CONFIG_PINCTRL=y -# Place application at &code_partition address +# Place application at &code_partition address, produce a .uf2 natively CONFIG_USE_DT_CODE_PARTITION=y +CONFIG_BUILD_OUTPUT_UF2=y # Enable NVS for settings CONFIG_MPU_ALLOW_FLASH_WRITE=y @@ -26,28 +29,45 @@ CONFIG_FLASH=y CONFIG_FLASH_PAGE_LAYOUT=y CONFIG_FLASH_MAP=y -# nRF52840 can handle faster USB polling -CONFIG_USB_HID_POLL_INTERVAL_MS=1 +# USB HID output is central-only at v0.3.0, so no ZMK_USB here — but keep the +# bare USB device stack so zmk_usb_is_powered() works and the display can show +# CHG vs BAT while charging (matches 2022 behaviour; the peripheral never sent +# HID over USB anyway). +CONFIG_USB_DEVICE_STACK=y +# Display (see left defconfig for stack/pool rationale) CONFIG_ZMK_DISPLAY=y CONFIG_ZMK_DISPLAY_WORK_QUEUE_DEDICATED=y -CONFIG_ZMK_DISPLAY_DEDICATED_THREAD_STACK_SIZE=2048 -CONFIG_LVGL_USE_THEME_MONO=y - +CONFIG_ZMK_DISPLAY_DEDICATED_THREAD_STACK_SIZE=4096 +CONFIG_LV_Z_MEM_POOL_SIZE=8192 +CONFIG_LV_USE_THEME_MONO=y +# White-on-black like the 2022 build: LVGL 8's mono theme defaults to the +# light variant (LVGL 7 defaulted dark); this flips it back. +CONFIG_ZMK_DISPLAY_INVERT=y +# Theme small font: UNSCII-8 like everything else (default is Montserrat 12) +CONFIG_ZMK_LV_FONT_DEFAULT_SMALL_UNSCII_8=y -#CONFIG_ZMK_BLE_CLEAR_BONDS_ON_START=y -CONFIG_BT_MAX_CONN=6 -#CONFIG_BT_CTLR_TX_PWR_PLUS_8=y +# 32kHz source is the internal RC on purpose. Y1/Y2 crystals are fitted but +# their load caps were never populated; the crystal was unstable in testing +# (2021) and was deliberately abandoned. Switching to K32SRC_XTAL will hang +# the boot waiting for LFCLK. Needs a PCB rev to ever revisit. CONFIG_CLOCK_CONTROL_NRF_K32SRC_RC=y + CONFIG_BT_CTLR_TX_PWR_PLUS_8=y -CONFIG_BT_PERIPHERAL_PREF_MIN_INT=6 -CONFIG_BT_PERIPHERAL_PREF_MAX_INT=12 +# BT_MAX_CONN / BT_PERIPHERAL_PREF_* deliberately unset: the peripheral now +# gets BT_MAX_CONN=1 (was hardcoded 6 — 6x oversized) and 6/12/30/400 conn +# params from ZMK's role-aware defaults. +# Debounce tuned against the actual switches for minimum first-press latency +# (2ms press) with chatter protection on release (10ms). Asymmetry is +# intentional — do not normalise to the 5/5 default. Last tuned 2022-03-05. CONFIG_ZMK_KSCAN_DEBOUNCE_PRESS_MS=2 CONFIG_ZMK_KSCAN_DEBOUNCE_RELEASE_MS=10 + CONFIG_ZMK_HID_CONSUMER_REPORT_USAGES_BASIC=y -# custom status screens +# Custom status screen: peripherals cannot see layer/endpoint state over the +# split GATT service, so the right half shows connection status + battery only. CONFIG_ZMK_DISPLAY_STATUS_SCREEN_CUSTOM=y CONFIG_CUSTOM_WIDGET_BATTERY_STATUS=y CONFIG_ZMK_WIDGET_BATTERY_STATUS=n @@ -55,5 +75,3 @@ CONFIG_CUSTOM_WIDGET_OUTPUT_STATUS=y CONFIG_ZMK_WIDGET_OUTPUT_STATUS=n CONFIG_CUSTOM_WIDGET_LAYER_STATUS=n CONFIG_ZMK_WIDGET_LAYER_STATUS=n -CONFIG_CUSTOM_WIDGET_WPM_STATUS=n -CONFIG_ZMK_WIDGET_WPM_STATUS=n \ No newline at end of file diff --git a/zmk-config/boards/arm/anaphase/board.c b/zmk-config/boards/arm/anaphase/board.c deleted file mode 100644 index 3dc661a..0000000 --- a/zmk-config/boards/arm/anaphase/board.c +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Copyright (c) 2021 - * - * SPDX-License-Identifier: MIT - */ - -#include -#include -#include - -static int board_anaphase_init(const struct device *dev) -{ - ARG_UNUSED(dev); - - const uint32_t key = irq_lock(); - - // Enable DC/DC regulator for REG0 stage. - // Default REGOUT0 is 1.8V, so no need to change that. - nrf_power_dcdcen_vddh_set(NRF_POWER, true); - - irq_unlock(key); - - return 0; -} - -SYS_INIT(board_anaphase_init, PRE_KERNEL_1, CONFIG_KERNEL_INIT_PRIORITY_DEFAULT); diff --git a/zmk-config/boards/arm/anaphase/custom_status_screen.c b/zmk-config/boards/arm/anaphase/custom_status_screen.c index 0ee496a..afd197e 100644 --- a/zmk-config/boards/arm/anaphase/custom_status_screen.c +++ b/zmk-config/boards/arm/anaphase/custom_status_screen.c @@ -7,10 +7,9 @@ #include "widgets/output_status.h" #include "widgets/battery_status.h" #include "widgets/layer_status.h" -#include "widgets/wpm_status.h" #include "custom_status_screen.h" -#include +#include LOG_MODULE_DECLARE(zmk, CONFIG_ZMK_LOG_LEVEL); #if IS_ENABLED(CONFIG_CUSTOM_WIDGET_BATTERY_STATUS) @@ -25,44 +24,33 @@ static struct zmk_widget_output_status output_status_widget; static struct zmk_widget_layer_status layer_status_widget; #endif -#if IS_ENABLED(CONFIG_CUSTOM_WIDGET_WPM_STATUS) -static struct zmk_widget_wpm_status wpm_status_widget; -#endif - -lv_style_t global_style; - +/* + * 96x48 text-only layout, UNSCII-8 theme font (set via Kconfig). + * Rows at x=32: name(1) / status(11) / battery(21) / layer(39). + * y=31 was the WPM counter, retired in the 2026 v0.3.0 migration. + * Layer row is 39 (was 41): lv_font_unscii_8 declares line_height 9 in + * LVGL 8 (was 8 in LVGL 7), and 39+9 = 48 exactly. + */ lv_obj_t *zmk_display_status_screen() { lv_obj_t *screen; - lv_style_init(&global_style); - lv_style_set_text_font(&global_style, LV_STATE_DEFAULT, &lv_font_unscii_8); - lv_style_set_text_letter_space(&global_style, LV_STATE_DEFAULT, 1); - lv_style_set_text_line_space(&global_style, LV_STATE_DEFAULT, 1); - - screen = lv_obj_create(NULL, NULL); - //lv_obj_add_style(screen, LV_LABEL_PART_MAIN, &global_style); + screen = lv_obj_create(NULL); #if IS_ENABLED(CONFIG_CUSTOM_WIDGET_BATTERY_STATUS) zmk_widget_battery_status_init(&battery_status_widget, screen); - lv_obj_align(zmk_widget_battery_status_obj(&battery_status_widget), NULL, LV_ALIGN_IN_TOP_LEFT, 32, 21); + lv_obj_align(zmk_widget_battery_status_obj(&battery_status_widget), LV_ALIGN_TOP_LEFT, 32, 21); #endif #if IS_ENABLED(CONFIG_CUSTOM_WIDGET_OUTPUT_STATUS) zmk_widget_output_status_init(&output_status_widget, screen); - lv_obj_align(zmk_widget_output_name_obj(&output_status_widget), NULL, LV_ALIGN_IN_TOP_LEFT, 32, 1); - lv_obj_align(zmk_widget_output_status_obj(&output_status_widget), NULL, LV_ALIGN_IN_TOP_LEFT, 32, 11); + lv_obj_align(zmk_widget_output_name_obj(&output_status_widget), LV_ALIGN_TOP_LEFT, 32, 1); + lv_obj_align(zmk_widget_output_status_obj(&output_status_widget), LV_ALIGN_TOP_LEFT, 32, 11); #endif #if IS_ENABLED(CONFIG_CUSTOM_WIDGET_LAYER_STATUS) zmk_widget_layer_status_init(&layer_status_widget, screen); - lv_obj_align(zmk_widget_layer_status_obj(&layer_status_widget), NULL, LV_ALIGN_IN_TOP_LEFT, 32, 41); -#endif - -#if IS_ENABLED(CONFIG_CUSTOM_WIDGET_WPM_STATUS) - zmk_widget_wpm_status_init(&wpm_status_widget, screen); - lv_obj_align(zmk_widget_wpm_status_obj(&wpm_status_widget), NULL, LV_ALIGN_IN_TOP_LEFT, 32, 31); + lv_obj_align(zmk_widget_layer_status_obj(&layer_status_widget), LV_ALIGN_TOP_LEFT, 32, 39); #endif - lv_refr_now(NULL); return screen; -} \ No newline at end of file +} diff --git a/zmk-config/boards/arm/anaphase/pre_dt_board.cmake b/zmk-config/boards/arm/anaphase/pre_dt_board.cmake new file mode 100644 index 0000000..e11ead0 --- /dev/null +++ b/zmk-config/boards/arm/anaphase/pre_dt_board.cmake @@ -0,0 +1,5 @@ +# SPDX-License-Identifier: MIT + +# Suppresses duplicate unit-address warnings at build time for power, clock, +# acl and flash-controller on the nRF52840 (same as upstream ZMK boards). +list(APPEND EXTRA_DTC_FLAGS "-Wno-unique_unit_address_if_enabled") diff --git a/zmk-config/boards/arm/anaphase/widgets/battery_status.c b/zmk-config/boards/arm/anaphase/widgets/battery_status.c index 1513787..62e29bd 100644 --- a/zmk-config/boards/arm/anaphase/widgets/battery_status.c +++ b/zmk-config/boards/arm/anaphase/widgets/battery_status.c @@ -4,15 +4,14 @@ * SPDX-License-Identifier: MIT */ -#include -#include +#include -#include +#include LOG_MODULE_DECLARE(zmk, CONFIG_ZMK_LOG_LEVEL); #include +#include #include "battery_status.h" -#include #include #include #include @@ -26,16 +25,14 @@ struct battery_status_state { }; static void set_battery_symbol(lv_obj_t *label, struct battery_status_state state) { - char text[10] = " "; - - uint8_t level = state.level; + char text[10]; if (state.usb_present) { - sprintf(text, "CHG:%3d%%", level); + snprintf(text, sizeof(text), "CHG:%3d%%", state.level); } else { - sprintf(text, "BAT:%3d%%", level); + snprintf(text, sizeof(text), "BAT:%3d%%", state.level); } - + lv_label_set_text(label, text); } @@ -45,8 +42,10 @@ void battery_status_update_cb(struct battery_status_state state) { } static struct battery_status_state battery_status_get_state(const zmk_event_t *eh) { - return (struct battery_status_state) { - .level = bt_bas_get_battery_level(), + const struct zmk_battery_state_changed *ev = as_zmk_battery_state_changed(eh); + + return (struct battery_status_state){ + .level = (ev != NULL) ? ev->state_of_charge : zmk_battery_state_of_charge(), .usb_present = zmk_usb_is_powered(), }; } @@ -58,7 +57,7 @@ ZMK_SUBSCRIPTION(widget_battery_status, zmk_battery_state_changed); ZMK_SUBSCRIPTION(widget_battery_status, zmk_usb_conn_state_changed); int zmk_widget_battery_status_init(struct zmk_widget_battery_status *widget, lv_obj_t *parent) { - widget->obj = lv_label_create(parent, NULL); + widget->obj = lv_label_create(parent); lv_obj_set_size(widget->obj, 64, 10); @@ -70,4 +69,4 @@ int zmk_widget_battery_status_init(struct zmk_widget_battery_status *widget, lv_ lv_obj_t *zmk_widget_battery_status_obj(struct zmk_widget_battery_status *widget) { return widget->obj; -} \ No newline at end of file +} diff --git a/zmk-config/boards/arm/anaphase/widgets/battery_status.h b/zmk-config/boards/arm/anaphase/widgets/battery_status.h index b87e87e..ce22da4 100644 --- a/zmk-config/boards/arm/anaphase/widgets/battery_status.h +++ b/zmk-config/boards/arm/anaphase/widgets/battery_status.h @@ -7,7 +7,7 @@ #pragma once #include -#include +#include struct zmk_widget_battery_status { sys_snode_t node; diff --git a/zmk-config/boards/arm/anaphase/widgets/layer_status.c b/zmk-config/boards/arm/anaphase/widgets/layer_status.c index 04b560f..bfeb6b2 100644 --- a/zmk-config/boards/arm/anaphase/widgets/layer_status.c +++ b/zmk-config/boards/arm/anaphase/widgets/layer_status.c @@ -4,37 +4,29 @@ * SPDX-License-Identifier: MIT */ -#include -#include +#include +#include LOG_MODULE_DECLARE(zmk, CONFIG_ZMK_LOG_LEVEL); #include #include "layer_status.h" #include #include -#include #include static sys_slist_t widgets = SYS_SLIST_STATIC_INIT(&widgets); struct layer_status_state { - uint8_t index; + zmk_keymap_layer_index_t index; const char *label; }; static void set_layer_symbol(lv_obj_t *label, struct layer_status_state state) { - if (state.label == NULL) { - char text[10] = " "; - - sprintf(text, "BASE"); - - lv_label_set_text(label, text); + // v0.3.0 returns "" (not NULL) for an unnamed layer — check both. + if (state.label == NULL || strlen(state.label) == 0) { + lv_label_set_text(label, "BASE"); } else { - char text[12] = " "; - - snprintf(text, 12, "%s", state.label); - - lv_label_set_text(label, text); + lv_label_set_text(label, state.label); } } @@ -44,8 +36,9 @@ static void layer_status_update_cb(struct layer_status_state state) { } static struct layer_status_state layer_status_get_state(const zmk_event_t *eh) { - uint8_t index = zmk_keymap_highest_layer_active(); - return (struct layer_status_state){.index = index, .label = zmk_keymap_layer_label(index)}; + zmk_keymap_layer_index_t index = zmk_keymap_highest_layer_active(); + return (struct layer_status_state){ + .index = index, .label = zmk_keymap_layer_name(zmk_keymap_layer_index_to_id(index))}; } ZMK_DISPLAY_WIDGET_LISTENER(widget_layer_status, struct layer_status_state, layer_status_update_cb, @@ -54,7 +47,7 @@ ZMK_DISPLAY_WIDGET_LISTENER(widget_layer_status, struct layer_status_state, laye ZMK_SUBSCRIPTION(widget_layer_status, zmk_layer_state_changed); int zmk_widget_layer_status_init(struct zmk_widget_layer_status *widget, lv_obj_t *parent) { - widget->obj = lv_label_create(parent, NULL); + widget->obj = lv_label_create(parent); lv_obj_set_size(widget->obj, 64, 10); @@ -66,4 +59,4 @@ int zmk_widget_layer_status_init(struct zmk_widget_layer_status *widget, lv_obj_ lv_obj_t *zmk_widget_layer_status_obj(struct zmk_widget_layer_status *widget) { return widget->obj; -} \ No newline at end of file +} diff --git a/zmk-config/boards/arm/anaphase/widgets/layer_status.h b/zmk-config/boards/arm/anaphase/widgets/layer_status.h index 3779351..a11c403 100644 --- a/zmk-config/boards/arm/anaphase/widgets/layer_status.h +++ b/zmk-config/boards/arm/anaphase/widgets/layer_status.h @@ -7,7 +7,7 @@ #pragma once #include -#include +#include struct zmk_widget_layer_status { sys_snode_t node; diff --git a/zmk-config/boards/arm/anaphase/widgets/output_status.c b/zmk-config/boards/arm/anaphase/widgets/output_status.c index 2678609..16e9387 100644 --- a/zmk-config/boards/arm/anaphase/widgets/output_status.c +++ b/zmk-config/boards/arm/anaphase/widgets/output_status.c @@ -4,23 +4,37 @@ * SPDX-License-Identifier: MIT */ -#include -#include +#include -#include +#include LOG_MODULE_DECLARE(zmk, CONFIG_ZMK_LOG_LEVEL); #include #include "output_status.h" #include + +#if IS_ENABLED(CONFIG_ZMK_SPLIT_ROLE_CENTRAL) + +/* + * Central half. At v0.3.0 the endpoint/BLE-profile APIs and their events are + * compiled for the central only (zmk/app/CMakeLists.txt gates endpoints.c, + * ble.c and their events behind (!ZMK_SPLIT || ZMK_SPLIT_ROLE_CENTRAL)), + * so everything in this branch is central-only by construction. + */ + #include #include -#include +#include #include #include #include -#if defined(CONFIG_ZMK_SPLIT_BLE_ROLE_CENTRAL) +/* + * Per-host nicknames for the 5 BLE profiles. This table is the sole record + * of the mapping — there is no upstream profile-naming feature (v0.3.0 has + * an empty name slot in struct zmk_ble_profile, but nothing ever writes it). + * Do not refactor away. + */ static const char name_array[5][8] = { {"HOME PC"}, {"WORK PC"}, @@ -28,49 +42,44 @@ static const char name_array[5][8] = { {"PHONE"}, {"MISC"} }; -#endif static sys_slist_t widgets = SYS_SLIST_STATIC_INIT(&widgets); struct output_status_state { - enum zmk_endpoint selected_endpoint; + struct zmk_endpoint_instance selected_endpoint; bool active_profile_connected; bool active_profile_bonded; - uint8_t active_profile_index; }; static struct output_status_state get_state(const zmk_event_t *_eh) { - return (struct output_status_state){.selected_endpoint = zmk_endpoints_selected(), - .active_profile_connected = - zmk_ble_active_profile_is_connected(), - .active_profile_bonded = !zmk_ble_active_profile_is_open(), - .active_profile_index = zmk_ble_active_profile_index()}; - ; + return (struct output_status_state){ + .selected_endpoint = zmk_endpoints_selected(), + .active_profile_connected = zmk_ble_active_profile_is_connected(), + .active_profile_bonded = !zmk_ble_active_profile_is_open(), + }; } -static void set_status_symbol(lv_obj_t *status_label, lv_obj_t *name_label, struct output_status_state state) { - char status_text[10] = " "; - char name_text[10] = " "; +static void set_status_symbol(lv_obj_t *status_label, lv_obj_t *name_label, + struct output_status_state state) { + char status_text[10] = ""; + char name_text[10] = ""; - switch (state.selected_endpoint) { - case ZMK_ENDPOINT_USB: - snprintf(status_text, 10, "USB MODE"); + switch (state.selected_endpoint.transport) { + case ZMK_TRANSPORT_USB: + snprintf(status_text, sizeof(status_text), "USB MODE"); break; - case ZMK_ENDPOINT_BLE: + case ZMK_TRANSPORT_BLE: if (state.active_profile_bonded) { if (state.active_profile_connected) { - snprintf(status_text, 10, "PAIRED"); + snprintf(status_text, sizeof(status_text), "PAIRED"); } else { - snprintf(status_text, 10, "WAITING"); + snprintf(status_text, sizeof(status_text), "WAITING"); } } else { - snprintf(status_text, 10, "UNPAIRED"); + snprintf(status_text, sizeof(status_text), "UNPAIRED"); } -#if defined(CONFIG_ZMK_SPLIT_BLE_ROLE_CENTRAL) - snprintf(name_text, 10, "%s", name_array[state.active_profile_index]); -#else - snprintf(name_text, 10, "ANAPHASE"); -#endif + snprintf(name_text, sizeof(name_text), "%s", + name_array[state.selected_endpoint.ble.profile_index]); break; } @@ -80,19 +89,64 @@ static void set_status_symbol(lv_obj_t *status_label, lv_obj_t *name_label, stru static void output_status_update_cb(struct output_status_state state) { struct zmk_widget_output_status *widget; - SYS_SLIST_FOR_EACH_CONTAINER(&widgets, widget, node) { set_status_symbol(widget->status_obj, widget->name_obj, state); } + SYS_SLIST_FOR_EACH_CONTAINER(&widgets, widget, node) { + set_status_symbol(widget->status_obj, widget->name_obj, state); + } } ZMK_DISPLAY_WIDGET_LISTENER(widget_output_status, struct output_status_state, output_status_update_cb, get_state) -ZMK_SUBSCRIPTION(widget_output_status, zmk_endpoint_selection_changed); +ZMK_SUBSCRIPTION(widget_output_status, zmk_endpoint_changed); ZMK_SUBSCRIPTION(widget_output_status, zmk_usb_conn_state_changed); ZMK_SUBSCRIPTION(widget_output_status, zmk_ble_active_profile_changed); +#else /* peripheral */ + +/* + * Peripheral half. The only connection state a v0.3.0 peripheral can see is + * its split link to the central, so PAIRED/WAITING now reports that link — + * which is what it effectively meant on this half in 2022 too (the old + * "active profile" of a peripheral *was* its bond to the central). + */ + +#include +#include + +static sys_slist_t widgets = SYS_SLIST_STATIC_INIT(&widgets); + +struct output_status_state { + bool connected; +}; + +static struct output_status_state get_state(const zmk_event_t *_eh) { + return (struct output_status_state){ + .connected = zmk_split_bt_peripheral_is_connected(), + }; +} + +static void set_status_symbol(lv_obj_t *status_label, lv_obj_t *name_label, + struct output_status_state state) { + lv_label_set_text(status_label, state.connected ? "PAIRED" : "WAITING"); + lv_label_set_text(name_label, "ANAPHASE"); +} + +static void output_status_update_cb(struct output_status_state state) { + struct zmk_widget_output_status *widget; + SYS_SLIST_FOR_EACH_CONTAINER(&widgets, widget, node) { + set_status_symbol(widget->status_obj, widget->name_obj, state); + } +} + +ZMK_DISPLAY_WIDGET_LISTENER(widget_output_status, struct output_status_state, + output_status_update_cb, get_state) +ZMK_SUBSCRIPTION(widget_output_status, zmk_split_peripheral_status_changed); + +#endif /* IS_ENABLED(CONFIG_ZMK_SPLIT_ROLE_CENTRAL) */ + int zmk_widget_output_status_init(struct zmk_widget_output_status *widget, lv_obj_t *parent) { - widget->status_obj = lv_label_create(parent, NULL); - widget->name_obj = lv_label_create(parent, NULL); + widget->status_obj = lv_label_create(parent); + widget->name_obj = lv_label_create(parent); lv_obj_set_size(widget->status_obj, 64, 10); lv_obj_set_size(widget->name_obj, 64, 10); diff --git a/zmk-config/boards/arm/anaphase/widgets/output_status.h b/zmk-config/boards/arm/anaphase/widgets/output_status.h index 243de07..3a81cc2 100644 --- a/zmk-config/boards/arm/anaphase/widgets/output_status.h +++ b/zmk-config/boards/arm/anaphase/widgets/output_status.h @@ -7,7 +7,7 @@ #pragma once #include -#include +#include struct zmk_widget_output_status { sys_snode_t node; diff --git a/zmk-config/boards/arm/anaphase/widgets/wpm_status.c b/zmk-config/boards/arm/anaphase/widgets/wpm_status.c deleted file mode 100644 index d37606d..0000000 --- a/zmk-config/boards/arm/anaphase/widgets/wpm_status.c +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) 2020 The ZMK Contributors - * - * SPDX-License-Identifier: MIT - */ - -#include -LOG_MODULE_DECLARE(zmk, CONFIG_ZMK_LOG_LEVEL); - -#include -#include "wpm_status.h" -#include -#include -#include -#include - -static sys_slist_t widgets = SYS_SLIST_STATIC_INIT(&widgets); - -struct wpm_status_state { - uint8_t wpm; -}; - -struct wpm_status_state wpm_status_get_state(const zmk_event_t *eh) { - return (struct wpm_status_state){.wpm = zmk_wpm_get_state()}; -}; - -void set_wpm_symbol(lv_obj_t *label, struct wpm_status_state state) { - char text[10] = " "; - - LOG_DBG("WPM changed to %i", state.wpm); - snprintf(text, sizeof(text), "WPM: %3d", state.wpm); - - lv_label_set_text(label, text); -} - -void wpm_status_update_cb(struct wpm_status_state state) { - struct zmk_widget_wpm_status *widget; - SYS_SLIST_FOR_EACH_CONTAINER(&widgets, widget, node) { set_wpm_symbol(widget->obj, state); } -} - -ZMK_DISPLAY_WIDGET_LISTENER(widget_wpm_status, struct wpm_status_state, wpm_status_update_cb, - wpm_status_get_state) -ZMK_SUBSCRIPTION(widget_wpm_status, zmk_wpm_state_changed); - -int zmk_widget_wpm_status_init(struct zmk_widget_wpm_status *widget, lv_obj_t *parent) { - widget->obj = lv_label_create(parent, NULL); - //lv_label_set_align(widget->obj, LV_LABEL_ALIGN_RIGHT); - - lv_obj_set_size(widget->obj, 64, 10); - - sys_slist_append(&widgets, &widget->node); - - widget_wpm_status_init(); - return 0; -} - -lv_obj_t *zmk_widget_wpm_status_obj(struct zmk_widget_wpm_status *widget) { return widget->obj; } \ No newline at end of file diff --git a/zmk-config/boards/arm/anaphase/widgets/wpm_status.h b/zmk-config/boards/arm/anaphase/widgets/wpm_status.h deleted file mode 100644 index 0592299..0000000 --- a/zmk-config/boards/arm/anaphase/widgets/wpm_status.h +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright (c) 2020 The ZMK Contributors - * - * SPDX-License-Identifier: MIT - */ - -#pragma once - -#include -#include - -struct zmk_widget_wpm_status { - sys_snode_t node; - lv_obj_t *obj; -}; - -int zmk_widget_wpm_status_init(struct zmk_widget_wpm_status *widget, lv_obj_t *parent); -lv_obj_t *zmk_widget_wpm_status_obj(struct zmk_widget_wpm_status *widget); \ No newline at end of file diff --git a/zmk-config/west.yml b/zmk-config/west.yml index 62c1a6a..9c0ec4e 100644 --- a/zmk-config/west.yml +++ b/zmk-config/west.yml @@ -5,7 +5,7 @@ manifest: projects: - name: zmk remote: zmkfirmware - revision: main + revision: v0.3.0 import: app/west.yml self: path: zmk-config diff --git a/zmk-portability-report.md b/zmk-portability-report.md new file mode 100644 index 0000000..ac6a5d3 --- /dev/null +++ b/zmk-portability-report.md @@ -0,0 +1,719 @@ +# anaphase — ZMK Portability Assessment + +**Scope:** read-only excavation of `zmk-config/` against ZMK **v0.3.0** (`edf5c08`, 2025-08-01). +**Date:** 2026-08-06. **Nothing in the repository was modified.** + +### How upstream claims were verified + +Every "still exists / was renamed / was removed" statement below was checked against actual +checked-out source, not recalled: + +| Tree | Revision | Used for | +|---|---|---| +| `zmkfirmware/zmk` | `v0.3.0` (`edf5c08`) | ZMK Kconfig symbols, DT bindings, behaviours, keycodes, widget APIs | +| `zmkfirmware/zephyr` | `v3.5.0+zmk-fixes` | Zephyr Kconfig, DT bindings, `init.h`, SoC init, board runners | +| `zephyrproject-rtos/lvgl` | `8a6a2d1` = **LVGL 8.3.7** | LVGL API surface | +| `zephyrproject-rtos/zephyr` | `v2.5.0` | what the 2022 build actually had (see §2.1) | + +Method for the Kconfig audit: all 12,004 `config`/`menuconfig`/`choice` symbols across the three +trees were indexed, then every symbol this repo references was looked up in that index. Results in +§C6. + +**Not verified:** no build was attempted (ground rule 1 forbids edits, and a build needs the fixes +below to even configure). Every claim is static analysis of source. Anything I could not confirm is +marked *unverified* and appears in §6. + +--- + +## 1. Executive summary + +**Overall risk: minor-to-substantial, concentrated in one place.** The keymap ports untouched. The +board devicetree needs mechanical modernisation. The display widgets need a real rewrite. + +Three things will consume the time: + +1. **The four LVGL widgets are LVGL 7 code and must be ported to LVGL 8.3.7.** This is the single + largest job (~60% of total effort). The brief guessed "minor at v0.3.0, expect 8.x→8.x churn" — + wrong. Your 2022 tree pinned **Zephyr 2.5.0**, which shipped **LVGL 7.6.1**. Every object-creation + call, every alignment call, every style call, and every Kconfig symbol in the display path + changed. Mitigating this substantially: ZMK v0.3.0 ships + `app/boards/arm/corneish_zen/` — a split nRF52840 **board** (not shield) with a display, a custom + status screen, and a near-identical four-widget set, already on LVGL 8. It is a line-by-line + reference for this exact port. + +2. **The devicetree SPI node uses the pre-pinctrl nRF pin properties.** `sck-pin`/`mosi-pin`/ + `miso-pin`/`miso-pull-down` were removed in Zephyr 3.0. This is a well-trodden rewrite into a + `&pinctrl` node, but it touches the one subsystem you cannot test without flashing. + +3. **The display device is found by a different mechanism now, and the current tree does not + provide it.** In 2022 ZMK looked the panel up by *device name string* + (`CONFIG_LVGL_DISPLAY_DEV_NAME`, default `"DISPLAY"`) — which is why `label = "DISPLAY"` is on the + OLED node. v0.3.0 does `DEVICE_DT_GET(DT_CHOSEN(zephyr_display))`. There is no + `zephyr,display` chosen entry anywhere in this repo, so the build fails at compile time until one + is added. Easy fix, but invisible unless you know to look — and it is the kind of thing that gets + "fixed" by deleting the mysterious `label` line, which makes it worse. + +**What the assessment brief got wrong**, and it is worth stating plainly because it removes an +entire work item: *there is no hand-written SSD1306 SPI driver.* Not in the tree, not in any commit +in the history, not referenced by any build hook. §C4 is a no-op. Details in §2.1. + +**Good news up front:** `zmk-config/west.yml` points at `zmkfirmware/zmk` on `main` — **not a fork**. +There are no out-of-tree patches to re-home. That was the brief's designated highest-risk unknown +and it came back clean. + +**Power is not a problem and should not be touched.** Measured baseline from the owner: a 700 mAh +cell on the central half lasts **~2 months** — roughly **490 µA** average, which for a BLE central +driving an OLED is healthy. Two apparent "power bugs" surfaced during the excavation and **both were +investigated to ground and both should be left alone**; the reasoning is in §5 M1 and M9, and the +short version is that one is a deliberate workaround for a PCB erratum and the other is worth +single-digit microamps. This matters mainly as a warning: they look like defects on a first read, +and a future migration that "fixes" M9 will produce a keyboard that does not boot. + +**Rollback:** the exact 2022 build is recoverable. See §6, question O1 — this was a deferred item +and the hard part of it fell out of the excavation for free. + +--- + +## 2. Feature inventory (Part A) + +Layout: 42 keys, split 21/21, three 12-column rows plus a 6-key thumb cluster. All keys are +**direct-wired** — one GPIO per switch, no matrix, no diodes. + +### 2.1 The premise correction — there is no custom display driver + +The brief states a hand-written SPI SSD1306 driver was required because mainline Zephyr's was +I²C-only. Every part of that is false for this repository: + +- **No driver source exists.** The full file list is 26 files; there is no `drivers/` directory, no + `.c` outside `board.c`, `custom_status_screen.c`, and the four widgets, and no `zephyr/module.yml` + or root `Kconfig`/`CMakeLists.txt` that could wire an out-of-tree driver in. `git log --all + --diff-filter=D` confirms no such file was ever deleted either — the complete set of paths that + have ever existed in this repo contains nothing driver-shaped. +- **The DT node binds the stock driver.** `anaphase.dtsi:53-72` declares + `compatible = "solomon,ssd1306fb"` as a child of `&spi0`, and `Kconfig.defconfig:72` sets + `CONFIG_SSD1306=y` — that is Zephyr's in-tree driver symbol. +- **Mainline supported SPI the whole time.** `dts/bindings/display/solomon,ssd1306fb-spi.yaml` + exists at Zephyr **v2.5.0** (confirmed HTTP 200 at that tag) and is copyright *2020, Marco Peter*. + It requires exactly the `data_cmd-gpios` property `anaphase.dtsi:57` sets. The same file is present + and unchanged in shape at v3.5.0. + +The plausible origin of the brief's belief: the commits `9f465ef "oled configured, but no output"` → +`d633f66 "working screen, but flipped polarity"` (2021-11-01→04) show real pain getting the panel +up. But that was *devicetree tuning* — `com-invdir`, `segment-remap`, `multiplex-ratio = <47>`, +`prechargep = <0x22>` for a non-standard 96×48 panel — not driver authorship. + +**MIPI DBI:** also not a concern at this target. Zephyr 3.5 has no `dts/bindings/mipi-dbi/` +directory at all; the SSD1306→MIPI-DBI migration landed later. It becomes relevant only at +Zephyr 4.1 (see appendix). + +### 2.2 Feature table + +| # | Feature | Defined in | Depends on | v0.3.0 risk | Status | +|---|---|---|---|---|---| +| 1 | Split BLE keyboard, left=central | `anaphase_left_defconfig:5-6`, `Kconfig.defconfig:23` | `ZMK_SPLIT`, `ZMK_SPLIT_BLE_ROLE_CENTRAL` | **clean** | Live. Symbol survives as a backwards-compat alias that `select`s `ZMK_SPLIT_ROLE_CENTRAL`. Rename recommended, not required. | +| 2 | Right half = peripheral | `anaphase_right_defconfig:6` (commented out) | absence of central role | **clean** | Live by omission. `ZMK_SPLIT_BLE_ROLE_PERIPHERAL` **no longer exists** — but it is commented out, so nothing breaks. Delete the dead line. | +| 3 | 42-key direct kscan, 21 GPIOs/half | `anaphase_left.dts`, `anaphase_right.dts` | `zmk,kscan-gpio-direct` | **minor** | Live. Binding intact. `label` deprecated; `wakeup-source` absent (see §C2). | +| 4 | Matrix transform, right `col-offset = <21>` | `anaphase.dtsi:24-34`, `anaphase_right.dts` tail | `zmk,matrix-transform` | **clean** | Live. Algorithm in `matrix_transform.c` is unchanged; lookup index is `(row*columns)+column`, and since direct-kscan always reports row 0, index == column ∈ 0…41 < len 42. Works. See §5 note on `columns = <12>`. | +| 5 | 5 layers: BASE/SHIFT/FUNC/FNSHFT/SYS | `anaphase.keymap:52-141` | `zmk,keymap` | **clean** | Live, all reachable. | +| 6 | Tri-layer SHIFT+FUNC→FNSHFT | `anaphase.keymap:14-21` | `zmk,conditional-layers` | **clean** | Live, binding unchanged. | +| 7 | 4 combos (ctrl+alt+mute, ctrl+shft, alt+shft, ctrl+alt+shft) | `anaphase.keymap:25-47` | `zmk,combos` | **clean** | Live. All at `timeout-ms = <50>`. Uses no deprecated combo Kconfigs. | +| 8 | Mod-taps `&mt` on positions 0/12/24 | `anaphase.keymap` BASE row 1-3 col 1 | `mod_tap.dtsi` | **clean** | Live. New opt-in properties available (§C3). | +| 9 | Media/consumer keys (`C_VOL_UP/DN`, `C_MUTE`) | keymap + combo | `ZMK_HID_CONSUMER_REPORT_USAGES_BASIC` | **clean** | Live. Symbol and all keycodes verified present. | +| 10 | 5 BLE profiles + `&bt BT_CLR` | `anaphase.keymap:131` (SYS layer) | `bluetooth.dtsi` | **clean** | Live. | +| 11 | `&bootloader` | `anaphase.keymap:137` | `reset.dtsi` | **clean** | Live, `bootloader` label present at v0.3.0. | +| 12 | USB + BLE output | `Kconfig.defconfig:38-42` | `ZMK_USB`, `ZMK_BLE` | **clean** | Live. | +| 13 | SSD1306 96×48 OLED per half, SPI0 @8 MHz | `anaphase.dtsi:42-72` | stock `solomon,ssd1306fb` | **substantial** | Live. Needs pinctrl rewrite + `zephyr,display` chosen. Driver itself needs nothing. | +| 14 | Custom status screen | `custom_status_screen.c` | `ZMK_DISPLAY_STATUS_SCREEN_CUSTOM` | **substantial** | Live. LVGL 7 API throughout. | +| 15 | Battery widget `BAT:nnn%` / `CHG:nnn%` | `widgets/battery_status.c` | `bt_bas_get_battery_level`, `zmk_usb_is_powered` | **substantial** | Live (left + right). LVGL 7 API. | +| 16 | Output widget — 2 labels, profile **nicknames** | `widgets/output_status.c` | endpoint + BLE profile API | **substantial** | Live (left + right). Uses the **removed** `enum zmk_endpoint` API. See §3-B6. | +| 17 | Layer widget (layer label text) | `widgets/layer_status.c` | `zmk_keymap_layer_label()` | **substantial** | Live (**left only**). Function **removed** at v0.3.0. | +| 18 | WPM widget `WPM: nnn` | `widgets/wpm_status.c` | `ZMK_WPM` | — | **Being dropped.** Live today (left only), but the owner does not use it and has elected to retire it rather than port it. Delete `wpm_status.{c,h}`, its `CMakeLists` line, its four Kconfig symbols, `select ZMK_WPM`, and its `zmk_widget_wpm_status_init`/`lv_obj_align` pair in `custom_status_screen.c`. Removes ~45 min of LVGL 8 porting and makes B8 moot. | +| 19 | Battery sense via VDDH | `anaphase.dtsi:36-39`, `Kconfig.defconfig:44` | `zmk,battery-nrf-vddh` | **clean** | Live. Compatible and Kconfig both present. | +| 20 | Idle 60 s / deep-sleep 2 h | `*_defconfig:8-9` | `ZMK_SLEEP` | **clean** | Live. All three symbols present. | +| 21 | UF2 post-build via `uf2conv.py` | `CMakeLists.txt:8-15` | `${ZEPHYR_BASE}/../tools/uf2/` | **minor** | **Obsolete** — replaced by `CONFIG_BUILD_OUTPUT_UF2`. §3-B3. | +| 22 | VDDH (REG0) DC/DC enable | `board.c:11-26` | `nrf_power_dcdcen_vddh_set` | **minor** | **Superseded** by `CONFIG_SOC_DCDC_NRF52X_HV`, and the `SYS_INIT` signature changed. §3-B4. | +| 23 | Adafruit bootloader flash layout | `anaphase.dtsi:82-113` | `fixed-partitions` | **clean** | Live. Standard nRF52840 Adafruit map. | +| 24 | Debounce 2 ms press / 10 ms release | `*_defconfig:44-45` | `ZMK_KSCAN_DEBOUNCE_*_MS` | **clean** | Live. Global-override Kconfigs still exist (default `-1` = defer to DT). Tuned in `9e7d2f7`, 2022-03-05 — the last commit to this config. | +| 25 | 1 ms USB HID polling | `*_defconfig:29` | `USB_HID_POLL_INTERVAL_MS` | **clean** | Live. | +| 26 | BLE conn interval 7.5–15 ms, TX +8 dBm, RC 32 kHz | `*_defconfig:38-42` | Zephyr BT | **clean** | All symbols present. `BT_MAX_CONN=6` questionable on the peripheral — §5. | +| 27 | Keyboard names "anaphase" / "anaphase rt" | `Kconfig.defconfig:5,13` | `ZMK_KEYBOARD_NAME` | **clean** | Live. | + +### 2.3 Dead code — defined but unreachable + +Reported separately, per ground rule. These are deletion candidates; the call is yours. + +| Item | Where | Why it is dead | What breaks if removed | +|---|---|---|---| +| `BOARD_ENABLE_DCDC` | `Kconfig:3-7` | `depends on BOARD_ANAPHASE`, a symbol that **does not exist**. `Kconfig.board` defines `BOARD_ANAPHASE_LEFT` and `_RIGHT`; `config BOARD` is a *string* set to `"anaphase"`, a different symbol entirely. An undefined dependency evaluates to `n`, so the option is unselectable and `SOC_DCDC_NRF52X` is never selected. | **Nothing today** — it has never done anything, which means the main REG1 DC/DC converter has been off since 2021. Investigated as a possible power defect and dismissed: at 1.8 V VDD and a measured ~490 µA baseline the upside is single-digit µA, and the module does not bond out the pins needed to confirm it would even work (§5 M1, §2.5). Fix the dependency so the option is honest; leave it `default n`. Copy-paste artefact from `nice_nano`, whose version reads `depends on (BOARD_NICE_NANO \|\| BOARD_NICE_NANO_V2)`. | +| `CONFIG_CUSTOM_WIDGET_WPM_STATUS` | `Kconfig.defconfig`, `*_defconfig` | Declared and set, but `CMakeLists.txt:20` compiles `wpm_status.c` under `CONFIG_CUSTOM_WIDGET_LAYER_STATUS` instead. The WPM symbol gates nothing. | Nothing — the bug was always masked (both symbols `y` on left, `n` on right). Resolved by deletion: the WPM widget is being retired (feature #18), so the symbol and the mis-gated line both go. §3-B8. | +| `menuconfig CUSTOM_WIDGET_*` (all four) | `Kconfig.defconfig:96-107` | `menuconfig` with no body — behaves as plain `config bool`. Harmless but misleading. | Nothing. | +| `zephyr_library_include_directories(${ZEPHYR_BASE}/drivers)` | `CMakeLists.txt:5` **and** `:28` | Duplicated verbatim. Inherited from the `numble`/`corneish_zen` template (v0.3.0's `corneish_zen/CMakeLists.txt` still carries the same line — upstream cargo-cult, not yours). | Nothing. | +| Commented-out lines | `*_defconfig:36,38,54-56`; `anaphase_right_defconfig:6` | `ZMK_BLE_CLEAR_BONDS_ON_START`, duplicate `BT_CTLR_TX_PWR_PLUS_8`, USB-logging block, dead peripheral-role line. | Nothing. Note `BT_CTLR_TX_PWR_PLUS_8` appears **once commented and once live** — the live one wins; this is confusing, not broken. | +| `miso-pin = <11>` | `anaphase.dtsi:51` | Comment says *"unused pin, but needed by SPI definition"* — true under the old nRF SPI binding. | Nothing at v0.3.0 — the property is gone and pinctrl lets you omit MISO entirely. Deliberate deletion, see §3-B2. | + +### 2.4 Everything else in the tree + +- **`hardware/`** — KiCad 5.99 (nightly) project, gerbers, `schematic.pdf`, and an + `anaphase-2021-11-16_132330.zip` backup. Custom footprint for the **E73-2G4M08S1C** nRF52840 + module — this is the "non-standard module" the brief refers to. Untouched by this migration, but + it is where GPIO assignments can be cross-checked if the DT ever comes into question. The netlist + (`anaphase.xml`) turned out to be decisive for two firmware questions — see §2.5. +- **`.gitignore`** — three entries worth noting: + `hardware/kicad_5.99/.xdp-anaphase.pro-tgFd8n` (KiCad lock file); + `zmk-config/boards/arm/anaphase/widgets/temp` (**a widget scratch directory that was never + committed** — if a fifth widget or an icon set ever existed, it was here and is gone); + `.vscode/settings.json` (deleted in `18e03fa`). +- **`README.md`** — two sentences, no build instructions. +- **No `.github/`, no `build.yaml`, no `zephyr/module.yml`, no `config/` directory, no `.conf` + file anywhere.** This has never been a CI-buildable user config; it was built locally with + `west build -s zmk/app -b anaphase_left` against a workspace that lived outside git. +- **Provenance** — the board was seeded from **numble** and **fissure**. `git log` shows + `d358ec7 "numble file structure"` and `2e44b48 "added in fissure files"`, and the tree carried + `numble.dts`/`numble.conf`/`numble_defconfig`/`numble.keymap` before they were renamed. The stale + comment `CMakeLists.txt:7` — *"must match &code_partition address in numble.dts"* — is the + surviving fingerprint. +- **The "pete" in `e505ad1 "working after memory allocation bug found by pete"`** is almost + certainly Pete Johanson, ZMK's then-maintainer (his name is on + `zmk,kscan-gpio-direct.yaml`). Relevant because it means that fix was reviewed by someone who knew + the display pipeline — see §5, M4. + +### 2.5 What the PCB netlist settles + +`hardware/kicad_5.99/anaphase.xml` is a KiCad netlist covering **both halves on one sheet** +(U1 = right, U2 = left, two of everything: E73 module, USB-C, MCP73831 charger, inductor, crystal). +It answers two questions the firmware alone cannot, and one of the answers reverses a finding. + +| Net / part | Reading | +|---|---| +| `U1.23 VDDH` → `+BATT` | LiPo sits **directly on VDDH**. High-voltage mode confirmed — `ZMK_BATTERY_NRF_VDDH` is correct, not aspirational. | +| `U1.19 VDD` → net **`+1V8`** | REGOUT0 is at its 1.8 V UICR default. `board.c`'s comment was accurate, and the net name is the designer saying so. | +| `L1 / L2` (10 µH, 0603): `DCCH ↔ +1V8` | The **REG0 / high-voltage buck inductor**, one per half. The stage `board.c` enables is the stage with hardware support. | +| `DCC` / `DEC4` | **Not module pins.** The E73 exposes only `VDD`, `VDDH`, `DCCH`, `VBUS`. The REG1 network is either internal to the module or absent — not determinable from this repo. See M1. | +| `Y1 / Y2` 32.768 kHz (Micro Crystal CM9V-T1A) → `XL1`/`XL2` (P0.00/P0.01) | Crystals **are fitted** on both halves… | +| `Net-(U1-Pad11)`, `Net-(U1-Pad13)` — **two nodes each** | …but there are **no load capacitors**. Each XL net contains exactly the SoC pin and the crystal pin, nothing else. This is the whole story behind M9. | +| `J3` "SSD1306 OLED" `.8` → `+1V8`, `.6` → `+BATT` | OLED **logic runs at 1.8 V**; its charge pump takes the battery directly. C13/C15 are the pump caps. | +| P0.00 / P0.01 | Not present in either half's `input-gpios` list — no kscan conflict either way. | + +Two consequences for the migration: the 1.8 V VDD rail caps how much the REG1 DC/DC could ever be +worth (M1), and the missing load caps make the RC-oscillator setting a **deliberate workaround +rather than an oversight** (M9, B12). Neither should be changed. + +--- + +## 3. Bodge register (Part B) + +| # | Bodge | Class | What breaks if dropped | Action | +|---|---|---|---|---| +| **B1** | `label = "DISPLAY"` on the OLED node (`anaphase.dtsi:56`) | **Superseded** | **The display, entirely — and not gracefully.** In 2022 ZMK resolved the panel by device-name string: `#define ZMK_DISPLAY_NAME CONFIG_LVGL_DISPLAY_DEV_NAME` then `device_get_binding()`, confirmed by reading `app/src/display/main.c` at `2128b2b` (2021-09-25, the last revision of that file before your final commit). Default value of that Kconfig was `"DISPLAY"`. At v0.3.0 the same file line 25 reads `DEVICE_DT_GET(DT_CHOSEN(zephyr_display))`. There is **no `zephyr,display` chosen entry in this repo**, so v0.3.0 fails at compile time. | Add `zephyr,display = &oled;` to the `chosen` block. Then the `label` is inert (Zephyr 3.5 marks `label` `deprecated: true` in `base.yaml:49-51`) and can go. **Do not delete the label without adding the chosen entry first** — that is the trap. | +| **B2** | `sck-pin`/`mosi-pin`/`miso-pin`/`miso-pull-down` on `&spi0` (`anaphase.dtsi:45-51`) | **Superseded** | SPI, therefore the display. These properties were removed from the nRF SPI bindings in Zephyr 3.0 in favour of pinctrl; at 3.5 they are unknown properties. | Rewrite as `&pinctrl { spi0_default / spi0_sleep }` using `NRF_PSEL(SPIM_SCK, 0, 8)` and `NRF_PSEL(SPIM_MOSI, 0, 6)`; reference via `pinctrl-0`/`pinctrl-1`/`pinctrl-names`. Add `CONFIG_PINCTRL=y`. Template: `zmk/app/boards/arm/nice_nano/nice_nano-pinctrl.dtsi`. MISO can be omitted — the panel is write-only. | +| **B3** | Hand-rolled UF2 post-build hook (`CMakeLists.txt:8-15`) | **Obsolete** | The `.uf2` artefact — you would get only `zephyr.bin` and could not drag-and-drop flash. | Delete the whole `set_property(...extra_post_build_commands...)` block; add `CONFIG_BUILD_OUTPUT_UF2=y`. Verified equivalent: `zephyr/Kconfig.zephyr:598` sets `BUILD_OUTPUT_UF2_FAMILY_ID` default `"0xada52840"` for `SOC_NRF52840_QIAA` — byte-identical to your hardcoded `-f 0xADA52840` — and the `-b 0x1000` base is derived from `CONFIG_USE_DT_CODE_PARTITION` + `&code_partition`, which you already set. The hardcoded path `${ZEPHYR_BASE}/../tools/uf2/utils/uf2conv.py` is also fragile: it reaches out of the Zephyr tree into a sibling west project. | +| **B4** | `board.c` manually calling `nrf_power_dcdcen_vddh_set()` from `SYS_INIT` | **Superseded** *and* **broken at 3.5** | High-voltage (REG0) DC/DC regulation — i.e. battery efficiency, since the cell feeds VDDH directly (consistent with `ZMK_BATTERY_NRF_VDDH`). Dropping it without a replacement raises idle current. | Two independent problems: **(a)** Zephyr 3.5 does this itself — `soc/arm/nordic_nrf/nrf52/soc.c:49-51`: `#if NRF_POWER_HAS_DCDCEN_VDDH && defined(CONFIG_SOC_DCDC_NRF52X_HV) nrf_power_dcdcen_vddh_set(NRF_POWER, true);`. **(b)** The `SYS_INIT` contract changed: `zephyr/include/zephyr/init.h:59-76` defines `union init_function` where the *system* variant is `int (*sys)(void)` — **no `const struct device *` argument**. `board_anaphase_init(const struct device *dev)` will not type-check. Recommended: delete `board.c` and `CMakeLists.txt:3-5`, and adopt the upstream Kconfig pattern (§C2). | +| **B5** | LVGL 7 widget/screen code (status screen + 3 widgets after WPM is retired) | **Superseded** | The entire OLED UI. | Full port to LVGL 8.3.7. §C5. | +| **B6** | `enum zmk_endpoint` / `zmk_ble_active_profile_index()` in `output_status.c` | **Superseded** | The output widget — and it is the widget carrying your **profile nicknames** (`HOME PC`, `WORK PC`, `TABLET`, `PHONE`, `MISC`, `output_status.c:24-30`). That mapping exists nowhere else and is the single most easily-lost feature in this migration. | Migrate to `struct zmk_endpoint_instance` (`zmk/endpoints_types.h`): `.selected_endpoint.transport` ∈ `{ZMK_TRANSPORT_USB, ZMK_TRANSPORT_BLE}`. Header `zmk/events/endpoint_selection_changed.h` → `zmk/events/endpoint_changed.h`; event `zmk_endpoint_selection_changed` → `zmk_endpoint_changed`. Reference: `corneish_zen/widgets/output_status.c`. **`zmk_ble_active_profile_index()` is *not* removed** — it is still declared at `zmk/ble.h:29`. `corneish_zen` stopped calling it only because the endpoint instance now also carries the index at `.selected_endpoint.ble.profile_index`; either source works. Your `name_array[state.active_profile_index]` lookup can stay exactly as written. **The nickname table itself is unaffected by anything upstream** — it is your own static C array, not a ZMK feature, and nothing in v0.3.0 replaces it (see B13). | +| **B7** | `zmk_keymap_layer_label()` in `layer_status.c:48` | **Superseded** | The layer-name display (`BASE`/`SHIFT`/`FUNC`/`FUNCSHFT`/`SYS`). | **C function removed, DT property still works.** Two separate things, and only the first is mandatory. **(a) C API:** `zmk_keymap_layer_label()` is gone; v0.3.0 uses `zmk_keymap_layer_name(zmk_keymap_layer_index_to_id(index))`, and `uint8_t index` becomes `zmk_keymap_layer_index_t`. The index→id hop is new: layers now have *stable IDs* separate from *positional indices* (`keymap.h:19-31`), because Studio can reorder them at runtime. `zmk_keymap_highest_layer_active()` returns an **index**; `zmk_keymap_layer_name()` takes an **id**. **(b) DT:** `label` is `deprecated: true` in `zmk,keymap.yaml:21-25`, but `keymap.c:60` reads `DT_PROP_OR(node, display_name, DT_PROP_OR(node, label, ""))` — **`label` is an explicit fallback, so your keymap needs no change at all.** Switching to `display-name` is tidiness, not migration. (Upstream's own deprecation text says *'Use "name" instead'*, which is wrong — the property is `display-name`.) **Porting gotcha:** the return value is now `""` rather than `NULL` for an unnamed layer, so your `if (state.label == NULL)` fallback to `"BASE"` will never fire. Harmless here since all five layers are labelled, but copy upstream's `NULL || strlen(...) == 0` check anyway. | +| **B13** | Hardcoded `name_array[5][8]` profile nicknames (`output_status.c:24-30`) | **Still required** | Your per-host labels (`HOME PC` … `MISC`). | **Port as-is; there is no upstream replacement, despite appearances.** v0.3.0 *does* have a profile-name slot — `struct zmk_ble_profile { char name[15]; … }` (`zmk/ble/profile.h`) with a getter `zmk_ble_active_profile_name()` (`ble.c:357`) — and the field is persisted along with the rest of the profile struct to `ble/profiles/%d`. But a grep across all of `app/` finds **no writer of `.name` and no caller of the getter**: the array is static, zero-initialised, and the function always returns `""`. It is a reserved slot for future (probably Studio-driven) naming, not a working feature. Do not refactor onto it. Worth re-checking at v0.4 — because the storage is already inside the persisted struct, whoever wires it up gets persistence for free, and at that point it would genuinely replace your array. | +| **B8** | `wpm_status.c` gated on `CONFIG_CUSTOM_WIDGET_LAYER_STATUS` (`CMakeLists.txt:20`) | **Obsolete — resolved by deletion** | Nothing. The bug was always masked (the two symbols move together: `y,y` left, `n,n` right), and the WPM widget is now being retired entirely (feature #18). | Delete the line rather than fix it. Noted here only so the mis-gating is on record — if WPM is ever revived, it must come back gated on `CONFIG_CUSTOM_WIDGET_WPM_STATUS`, not the layer symbol. | +| **B9** | `#define K(n) RC(0,n)` + `columns = <12>` (`anaphase.dtsi:23-27`) | **Still required** | The whole keymap-to-switch mapping. | Port as-is; it is correct (§2.2 row 4). But `columns = <12>` / `rows = <4>` describe the *keymap grid*, while the actual kscan is 1 row × 42 columns. It works only because direct-kscan always reports row 0, making the lookup index `(0 × 12) + column` = `column`. Load-bearing coincidence. Leave it alone and add a comment; do not "tidy" it. | +| **B10** | Config lives in `_defconfig`, no `.conf` anywhere | **Still required** *(by choice)* | Nothing functional. | Legitimate for a board (vs. shield), but it means board defaults and user preferences are indistinguishable. `anaphase.conf` existed and was deleted in `d633f66` (2021-11-04); `anaphase.conf.bak` in `56460a7`. Optional cleanup: move preference-shaped settings (sleep timeouts, debounce, TX power) into `zmk-config/anaphase.conf`. | +| **B11** | `west.yml` at `zmk-config/`, `revision: main` | **Still required** | Reproducibility — `main` today is Zephyr 4.1 and will not build this tree at all. | **Pin to `v0.3.0`.** Path is fine: ZMK's `app/keymap-module/modules/modules.cmake:51-54` appends `${ZMK_CONFIG}/boards` to `BOARD_ROOT`, and the CI workflow accepts a `config_path` input, so `zmk-config/` needs no renaming. | +| **B12** | `CONFIG_CLOCK_CONTROL_NRF_K32SRC_RC=y` on both halves (`*_defconfig:39`) | **Still required** | **The keyboard's ability to boot.** This is the most dangerous entry in the register, because it is a workaround that reads as a defect. The board *has* 32.768 kHz crystals fitted (Y1/Y2, wired to XL1/XL2 on both modules) — so the obvious conclusion on reading the config is "the crystals are being ignored, switch to `_XTAL` and win back power." **Do not.** The netlist shows each XL net has exactly two nodes, the SoC pin and the crystal pin: **the load capacitors were never fitted.** Owner confirms: the crystal was unstable in testing and RC was a deliberate revert. Switching to `_XTAL` makes Zephyr block waiting on an LFCLK that cannot start reliably. | **Port unchanged.** Add a comment in the defconfig recording *why*, because nothing in the repo currently does and the hardware actively argues the other way. Prerequisite for ever revisiting this is a **PCB revision** adding ~9 pF load caps per the CM9V-T1A datasheet — a hardware change, out of scope here. | + +### Comment scan + +A literal grep for `HACK|FIXME|TODO|XXX|WORKAROUND|temporary|don't remove|broken|upstream|patch` +across all source, DT, Kconfig, CMake and YAML returned **zero hits**. 2022-you left no warning +notes. The only two explanatory comments in the tree are both stale: + +- `anaphase.dtsi:50` — *"unused pin, but needed by SPI definition"* → no longer true (B2). +- `CMakeLists.txt:7` — *"must match &code_partition address in numble.dts"* → references a file that + has not existed since 2021 (B3 deletes the block anyway). + +The commit log carries far more signal than the comments. `bf0787a "maybe solved mutex on battery"`, +`8f58ead "mutex problem with battery level"`, `9592eea "layer status working, but maybe issue with +work queue"`, `e505ad1 "working after memory allocation bug found by pete"` — all four are display +threading/memory bugs, all resolved by `0ee94ae "updated to stock listener macro"`. See §5, M4. + +--- + +## 4. Subsystem verdicts (Part C) + +### C1. Build system — **minor** *(brief predicted: minor ✓)* + +Nothing here exists yet: no workflow, no build matrix, and the manifest points at a moving target. + +- **`.github/workflows/build.yml`** — does not exist. Create it calling the shared reusable + workflow. Because the config directory is `zmk-config/`, not `config/`, you must override the + input (verified: `build-user-config.yml` declares `config_path` with `default: "config"`, and uses + it for both `west init -l` and `-DZMK_CONFIG=`): + ```yaml + on: [push, pull_request, workflow_dispatch] + jobs: + build: + uses: zmkfirmware/zmk/.github/workflows/build-user-config.yml@v0.3.0 + with: + config_path: zmk-config + ``` + Pin the workflow to `@v0.3.0` rather than `@main` — `@main` pulls a workflow that assumes + Zephyr 4.1 and will fight your pinned manifest. +- **`build.yaml`** — does not exist. Create at repo root. Correct board identifiers, derived from + the `*_defconfig` filenames (which is what Zephyr HWMv1 keys on): + ```yaml + include: + - board: anaphase_left + - board: anaphase_right + ``` + Confirmed correct: board IDs gain SoC qualifiers only under HWMv2 (Zephyr 4.1), so plain names + hold at v0.3.0. +- **`zmk-config/west.yml`** — change `revision: main` → `revision: v0.3.0`. No fork to resolve. +- One caveat: the shared workflow's `zmk-build-arm:stable` container tracks whatever ZMK `main` + needs. Pinning the *workflow* to `@v0.3.0` is what keeps the toolchain aligned. If CI later breaks + mysteriously, this is the first place to look. + +### C2. Board definition — **substantial** *(brief predicted: minor to substantial → substantial)* + +HWMv1 survives through Zephyr 3.6, so the directory layout, `Kconfig.board`, `Kconfig.defconfig` +and `*_defconfig` structure all stay. No `board.yml`, no restructuring. Confirmed: ZMK v0.3.0's own +boards still live at `app/boards/arm//`. All the work is drift. + +1. **pinctrl migration** (B2) — the largest single item here. +2. **`zephyr,display` chosen** (B1) — one line, blocks the build. +3. **`board.c` deletion** (B4) — replace with the upstream Kconfig idiom: + ``` + config BOARD_ENABLE_DCDC + bool "Enable DCDC mode" + select SOC_DCDC_NRF52X + default n # see §5 M1 — deliberately off, matches four years of shipped behaviour + depends on (BOARD_ANAPHASE_LEFT || BOARD_ANAPHASE_RIGHT) + + config BOARD_ENABLE_DCDC_HV + bool "High voltage DCDC converter" + select SOC_DCDC_NRF52X_HV + default y + depends on (BOARD_ANAPHASE_LEFT || BOARD_ANAPHASE_RIGHT) + ``` + Note the asymmetric defaults, both chosen to reproduce today's behaviour exactly. `default y` on + the **HV** option because `board.c` has always forced VDDH DC/DC on (`nice_nano_v2` uses `n` + there, but it is USB/LiPo-fed through a regulator — different topology, and the netlist confirms + yours puts the cell straight on VDDH). `default n` on the **main** option because the broken + `depends on` means REG1 DC/DC has never once run; fixing the dependency without also pinning the + default to `n` would silently enable a converter for the first time in four years, on a board + whose `DCC`/`DEC4` network cannot be verified. §5 M1 has the full reasoning. The point of fixing + the typo is that the option becomes *reachable and honest*, not that it becomes *on*. +4. **`wakeup-source` on the kscan node** — absent. Not required by + `zmk,kscan-gpio-direct.yaml` (it inherits `kscan.yaml` → `base.yaml`), so this is not a break. + But without it, waking from `ZMK_SLEEP` after the 2-hour deep-sleep timeout depends on GPIO + latch behaviour rather than a declared wakeup source. Given feature #20 is live and the sleep + timeout was deliberately tuned (`86e429d`, `9a52279`), **add `wakeup-source;`** and verify wake + works. Flagging as a likely pre-existing weakness rather than a regression risk. +5. **`label` on kscan/battery/OLED nodes** — deprecated at 3.5 (`base.yaml:49-51`), still accepted. + `zmk,kscan` and `zmk,battery` are already resolved via `chosen`, so these three labels are inert + once B1 is done. Low-priority cleanup. +6. **`diode-direction`, row/col GPIO ordering** — N/A, this is a direct-wire board with no matrix. +7. **GPIO flag forms** — `(GPIO_ACTIVE_LOW | GPIO_PULL_UP)` is current syntax; all 42 lines are fine. +8. **`pre_dt_board.cmake`** — absent. v0.3.0 boards ship one to suppress the duplicate-unit-address + warning on nRF52840 (`power`/`clock`/`acl`/`flash-controller`). Cosmetic; copy + `corneish_zen/pre_dt_board.cmake` to silence build noise. +9. **`anaphase.yaml` / `anaphase.zmk.yml`** — absent. The former is twister metadata, the latter is + ZMK's keyboard catalogue entry. *Unverified* whether HWMv1 board resolution strictly requires the + `.yaml`; I believe not (discovery keys on `Kconfig.board` + `*_defconfig`), but I did not build. + Add both regardless — they cost nothing. +10. **Split roles** — confirmed: **two board definitions** (`anaphase_left`, `anaphase_right`) + sharing `anaphase.dtsi`, not one board with variants. Left is central. This is unaffected by the + migration; noted because the brief asked. + +### C3. Keymap — **clean** *(brief predicted: clean ✓)* + +Confirmed rather than assumed. **`anaphase.keymap` needs no changes to build.** + +- All behaviours verified present in `app/dts/behaviors/`: `key_press.dtsi` (`&kp`), + `momentary_layer.dtsi` (`&mo`), `mod_tap.dtsi` (`&mt`), `transparent.dtsi` (`&trans`), + `none.dtsi` (`&none`), `bluetooth.dtsi` (`&bt`), `reset.dtsi` (`&bootloader`). +- All 38 distinct keycodes used were checked individually against + `app/include/dt-bindings/zmk/keys.h`: **zero missing**, including every consumer code and every + symbol alias (`TILDE AMPS PRCNT CARET PIPE LBKT LPAR LBRC UNDER EXCL DLLR BSLH RBKT RPAR RBRC SQT + FSLH SEMI PSCRN PG_UP PG_DN GRAVE MINUS PLUS EQUAL STAR HASH AT …`). +- `zmk,conditional-layers` and `zmk,combos` bindings unchanged. The tri-layer arrangement ports + untouched. +- **Deprecated combo Kconfigs** — this config sets **none** of them. Not applicable. +- **Parametrised macros** — **none used**. The v0.3.0 semantics fix cannot affect you. +- **Forked-feature bindings** — none. No mouse keys, no `&mwh`/`&msc`, nothing from an unmerged PR. + The mouse-key concern in the brief is moot: `west.yml` never pointed at a fork. +- **One cosmetic change available:** `label = "BASE"` etc. on the five layer nodes is deprecated in + the binding, but `keymap.c:60` falls back to `label` when `display-name` is absent, so the layer + names keep working untouched (B7). Renaming to `display-name` is optional tidiness — worth doing + only because the layer *widget* has to be opened anyway. + +**Available improvements, not required changes** (per the brief's framing): `&mt` now supports +`require-prior-idle-ms` (suppresses hold activation during fast typing) and +`hold-trigger-on-release`. Your three mod-taps sit on the leftmost column of each row and one of +them is `&mt LSHFT ESC` — a classic misfire candidate. Worth trying *after* a working baseline +exists, not during. + +**Design note, not a defect:** SHIFT is implemented as a full duplicate layer of `LS(…)` keycodes +rather than as a modifier. That is why combos B7's siblings exist at all — +`combo_ctrlshft` / `combo_altshft` / `combo_ctrlaltshft` (`anaphase.keymap:31-46`) synthesise the +modifier combinations that a layer-based shift cannot produce. These three combos are *load-bearing +consequences* of the layer design. If anyone ever "simplifies" SHIFT into a `&kp LSHFT`, those +combos become redundant — and conversely, dropping the combos while keeping layer-shift silently +removes your only way to press Ctrl+Shift. Documented here because nothing in the repo says it. + +### C4. SSD1306 SPI driver — **not applicable** *(brief predicted: obsolete, but verify → premise false)* + +There is no custom driver. See §2.1 for the evidence chain. Confirmed with you before finalising. + +The stock driver is used and continues to work: + +- **Binding supports SPI at 3.5?** Yes — `solomon,ssd1306fb-spi.yaml`, requiring `data_cmd-gpios`, + which `anaphase.dtsi:57` provides. +- **MIPI-DBI structure required?** No. Zephyr 3.5 has no `dts/bindings/mipi-dbi/` at all. The panel + stays a direct child of `&spi0`. +- **Feature parity?** Not a question that arises — there is nothing to compare against. All the + panel-specific tuning lives in devicetree properties, every one of which is still declared in + `solomon,ssd1306fb-common.yaml` at 3.5: `segment-offset`, `page-offset`, `display-offset`, + `multiplex-ratio`, `prechargep`, `com-invdir`, `segment-remap`, `reset-gpios`. Your non-standard + 96×48 geometry and `multiplex-ratio = <47>` carry over verbatim. + +Only change needed inside the display node: none. All the work is in the *bus* (B2) and the +*chosen* (B1). + +### C5. LVGL widgets — **substantial** *(brief predicted: minor → wrong, this is a 7→8 port)* + +The brief's instruction was to determine the actual version delta. Here it is: + +| | LVGL | Source | +|---|---|---| +| 2022 tree | **7.6.1** | ZMK `main` @ 2022-03-05 pinned `zephyr: v2.5.0+zmk-fixes`; Zephyr v2.5.0's `west.yml` pins lvgl `31acbaa` | +| v0.3.0 | **8.3.7** | Zephyr `v3.5.0+zmk-fixes` `west.yml:276` pins lvgl `8a6a2d1`, whose `lvgl.h:16-18` reads MAJOR 8 / MINOR 3 / PATCH 7 | + +This is the real port the brief said to expect if the old code turned out to be 7.x. It is. The +LVGL 7 fingerprints are unambiguous — two-argument constructors, state-parameterised styles, +`LV_ALIGN_IN_*`, and an `#include ` (`battery_status.c:15`) reaching into +an internal 7.x path that does not exist in 8.x. + +API changes required, all verified against `corneish_zen`'s already-ported equivalents: + +| LVGL 7 (current) | LVGL 8.3.7 | Sites | +|---|---|---| +| `lv_obj_create(NULL, NULL)` | `lv_obj_create(NULL)` | `custom_status_screen.c:42` | +| `lv_label_create(parent, NULL)` | `lv_label_create(parent)` | all 5 widget objects | +| `lv_obj_align(obj, NULL, LV_ALIGN_IN_TOP_LEFT, x, y)` | `lv_obj_align(obj, LV_ALIGN_TOP_LEFT, x, y)` | 5 calls, `custom_status_screen.c:47-63` | +| `lv_style_set_text_font(&s, LV_STATE_DEFAULT, f)` | `lv_style_set_text_font(&s, f)` — or drop the style and use `lv_obj_set_style_text_font(obj, f, LV_PART_MAIN)` | `custom_status_screen.c:38-40` | +| `LV_LABEL_PART_MAIN` | `LV_PART_MAIN` | commented out at `:43`, revive or delete | +| `#include ` | remove — no replacement needed | `battery_status.c:15` | +| `lv_refr_now(NULL)` | unchanged, but ZMK now calls it — remove | `custom_status_screen.c:66` | + +Plus the Zephyr-side changes that hit the same files: + +- **Every include gains a `zephyr/` prefix.** `` → ``, + `` → ``, `` → ``, + `` → ``. The legacy flat paths were + deprecated in Zephyr 3.2 and the `CONFIG_LEGACY_INCLUDE_PATH` escape hatch is gone by 3.5. + Affects all 6 `.c` files and all 5 headers (`lvgl.h` itself is unprefixed and stays). +- **`ZMK_DISPLAY_WIDGET_LISTENER` / `ZMK_SUBSCRIPTION` macros are unchanged** — the widget skeleton + survives intact. Good news: `0ee94ae "updated to stock listener macro"` moved you onto the + supported abstraction in 2022, and that decision is still paying off. Only the bodies change. +- Widget-specific API breaks: B6 (output), B7 (layer). + +**Does mainline now cover anything these widgets hand-roll?** Partly, and it is worth knowing before +porting: + +- v0.2.0 added a configurable display update period — now `CONFIG_ZMK_DISPLAY_TICK_PERIOD_MS` + (default 10, matching your old hardcoded `TICK_MS 10`). Nothing to hand-roll. +- v0.2.0 added display on/off pin support (`zmk,display-led` chosen). Not applicable — your panel + has no separate enable pin. +- v0.2.0 fixed defaulting to the mono theme only at 1 bpp. Your `Kconfig.defconfig:90-93` forces + `LVGL_COLOR_DEPTH_1` explicitly, so you were already correct by accident. +- **`CONFIG_ZMK_DISPLAY_BLANK_ON_IDLE` is a new symbol but not new behaviour.** It defaults to `y` + when `SSD1306` is set (`zmk/app/src/display/Kconfig`). 2022 ZMK blanked the display on idle + **unconditionally** — `display_event_handler` at `2128b2b` calls `stop_display_updates()` → + `display_blanking_on()` for both `ZMK_ACTIVITY_IDLE` and `ZMK_ACTIVITY_SLEEP`, with no Kconfig + gate. The symbol was added later to let people *opt out*. So **leaving it at the default preserves + current behaviour**; setting it to `n` would be the change. Nothing to decide. (This also explains + the measured battery life: with a 60 s idle timeout, the OLED — comfortably the largest consumer + on the board — is already off for the overwhelming majority of the time.) +- **Layer names became runtime-mutable**, which is new since 2022 but off by default. With + `CONFIG_ZMK_KEYMAP_SETTINGS_STORAGE` (`app/Kconfig:257`, depends on `SETTINGS` + + `ZMK_BEHAVIOR_LOCAL_IDS`, no `default y`), the name table becomes a writable + `char[][CONFIG_ZMK_KEYMAP_LAYER_NAME_MAX_LEN]` (default 20) and `zmk_keymap_set_layer_name()` + persists edits to `keymap/l_n/%d`. Leave it off and you get exactly today's static behaviour from + devicetree. Relevant only if you later take up ZMK Studio. +- The built-in status screen still cannot do what yours does (profile nicknames, 96×48 text layout), + so replacing your custom screen with the stock one is not an option. + +**The strongest lever on this whole migration:** `zmk/app/boards/arm/corneish_zen/` is a split +nRF52840 board with a display, `ZMK_DISPLAY_STATUS_SCREEN_CUSTOM`, and `battery_status` / +`output_status` / `layer_status` widgets carrying the *same* upstream ancestry as yours (both +descend from ZMK's 2021 widget templates) — already ported to LVGL 8. Diff your widget against its +counterpart rather than porting from the LVGL migration guide. + +### C6. `.conf` symbol audit — **minor**, but with one large cluster + +Every symbol this repo references was resolved against the 12,004-symbol index. **All 66 resolve +except 15**, and 14 of those 15 are one cluster. + +**Removed — the LVGL Kconfig namespace moved wholesale from `LVGL_*` to `LV_*` / `LV_Z_*`** when +Zephyr adopted LVGL 8's own Kconfig. All 14 are in `Kconfig.defconfig` and `*_defconfig`: + +| Removed symbol | Replacement at v0.3.0 | Evidence | +|---|---|---| +| `LVGL_FONT_UNSCII_8` | `LV_FONT_UNSCII_8` | lvgl 8.3.7 Kconfig | +| `LVGL_USE_LABEL` | `LV_USE_LABEL` | `corneish_zen/Kconfig.defconfig:40` | +| `LVGL_HOR_RES_MAX` = 96 | **none — deleted.** Resolution now comes from the display device's DT `width`/`height` | not in index | +| `LVGL_VER_RES_MAX` = 48 | **none — deleted.** Same | not in index | +| `LVGL_VDB_SIZE` = 100 | `LV_Z_VDB_SIZE` | `Kconfig.memory:41` | +| `LVGL_DPI` = 50 | `LV_DPI_DEF` | `modules/lvgl/Kconfig:18` | +| `LVGL_BITS_PER_PIXEL` = 1 | `LV_Z_BITS_PER_PIXEL` | `Kconfig.memory:7` | +| `choice LVGL_COLOR_DEPTH` / `LVGL_COLOR_DEPTH_1` | `LV_COLOR_DEPTH` / `LV_COLOR_DEPTH_1` | `modules/lvgl/Kconfig:40-51` | +| `LVGL_USE_THEME_MONO` | `LV_USE_THEME_MONO` | `zmk/app/src/display/Kconfig` | +| `choice LVGL_THEME_DEFAULT_FONT_NORMAL` + `…_UNSCII_8` | **restructured** → `choice LV_FONT_DEFAULT` / `LV_FONT_DEFAULT_*`, plus ZMK's own `choice ZMK_LV_FONT_DEFAULT_SMALL` which has a `ZMK_LV_FONT_DEFAULT_SMALL_UNSCII_8` option | `zmk/app/src/display/Kconfig` | +| `choice LVGL_THEME_DEFAULT_FONT_SMALL` + `…_UNSCII_8` | same as above | same | + +Two of these deserve emphasis. **`LVGL_HOR_RES_MAX`/`VER_RES_MAX` have no replacement** — LVGL 8 +takes resolution from the display driver. So the 96×48 geometry becomes entirely dependent on +`width`/`height` in the DT node (which are already correct). Nothing to port; just do not go looking +for where to re-declare 96 and 48. And the **UNSCII-8 font selection is the one that determines +whether your 96×48 text layout still fits** — ZMK's default is Montserrat, which will not. +`ZMK_LV_FONT_DEFAULT_SMALL_UNSCII_8` + `LV_FONT_UNSCII_8` are the two you need. + +**Removed — non-LVGL (1):** + +| Symbol | Status | +|---|---| +| `ZMK_SPLIT_BLE_ROLE_PERIPHERAL` | Removed. Peripheral is now implied by `ZMK_SPLIT` without `ZMK_SPLIT_ROLE_CENTRAL`. **Already commented out** at `anaphase_right_defconfig:6`, so no functional impact — delete the line. | + +**Present but semantically changed — these are the sneaky ones:** + +*(Two of the entries below — `ZMK_USB` and the peripheral API removal — were found by the actual +build, not the static audit. Both are cases where the symbol/function still exists but upstream +narrowed **who** gets it, which a symbol-existence check cannot see.)* + +| Symbol | Change | +|---|---| +| `ZMK_USB` | Now `depends on (!ZMK_SPLIT \|\| ZMK_SPLIT_ROLE_CENTRAL)` — **USB HID output is central-only**. Forcing it `default y` from a board Kconfig.defconfig on the peripheral half half-applies: the symbol goes `y` (the unguarded default wins) but the guarded `select USB_DEVICE_HID` never fires, producing an undefined-reference link failure in `usb_hid.c`. Resolution: `ZMK_USB` on the left only; the right keeps bare `USB_DEVICE_STACK=y` so `zmk_usb_is_powered()` and the CHG indicator still work (the peripheral never sent HID over USB anyway). | +| Peripheral API surface | `endpoints.c`, `ble.c`, `keymap.c` and their events are compiled **only** for `(!ZMK_SPLIT \|\| ZMK_SPLIT_ROLE_CENTRAL)` at v0.3.0. The 2022 peripheral could call `zmk_endpoints_selected()` / `zmk_ble_active_profile_*`; the v0.3.0 peripheral cannot — B6's claim that the output widget's `"ANAPHASE"` fallback "ports as-is" was wrong at link time. The widget now has an explicit peripheral branch built on `zmk_split_bt_peripheral_is_connected()` / `zmk_split_peripheral_status_changed`, which preserves the 2022 display semantics exactly (PAIRED/WAITING on the right always effectively meant "linked to the left half"). This also retroactively explains why layer status never worked on the right: `keymap.c` isn't there. | +| Choice-default overrides from board `Kconfig.defconfig` | Unreliable for choices ZMK itself defaults (observed: `choice ZMK_LV_FONT_DEFAULT_SMALL` override lost to ZMK's `MONTSERRAT_12` default; yet the same pattern worked for `ZMK_DISPLAY_STATUS_SCREEN`). Robust form: set the choice *symbol* directly in `*_defconfig` (`CONFIG_ZMK_LV_FONT_DEFAULT_SMALL_UNSCII_8=y`) — a user value on a choice symbol always wins. | +| `ZMK_SPLIT_BLE_ROLE_CENTRAL` | Still exists, but only as a compatibility shim: `app/src/split/bluetooth/Kconfig:15-17` is a bare `bool` that `select`s `ZMK_SPLIT_ROLE_CENTRAL`, with the comment *"Added for backwards compatibility. New shields / board should set `ZMK_SPLIT_ROLE_CENTRAL` only."* **Consequence beyond the rename:** `output_status.c:23` and `:69` branch on `#if defined(CONFIG_ZMK_SPLIT_BLE_ROLE_CENTRAL)` to decide whether to show profile nicknames or the literal `"ANAPHASE"`. Keep the old symbol *or* update both the defconfig and the two `#if`s — mixing them silently gives the right half the left half's nickname table. | +| `BT_MAX_CONN` = 6 | Still exists, but v0.3.0 now supplies role-aware defaults: `Kconfig.defaults:16-17` sets **6 for central**, `bluetooth/Kconfig:96-97` sets **1 for peripheral**. Your `=6` on the peripheral is now 6× the connection contexts it needs. See §5 M2. | + +**All other 49 symbols verified present**, including every one the brief flagged as worth watching: +`ZMK_SLEEP`, `ZMK_IDLE_TIMEOUT`, `ZMK_IDLE_SLEEP_TIMEOUT`, `ZMK_KSCAN_DEBOUNCE_PRESS_MS`, +`ZMK_KSCAN_DEBOUNCE_RELEASE_MS`, `ZMK_DISPLAY_WORK_QUEUE_DEDICATED`, +`ZMK_DISPLAY_DEDICATED_THREAD_STACK_SIZE`, `ZMK_DISPLAY_STATUS_SCREEN_CUSTOM`, +`ZMK_HID_CONSUMER_REPORT_USAGES_BASIC`, `ZMK_BATTERY_NRF_VDDH`, `ZMK_KEYBOARD_NAME`, +`USB_HID_POLL_INTERVAL_MS`, `BT_CTLR_TX_PWR_PLUS_8`, `BT_PERIPHERAL_PREF_MIN_INT`/`MAX_INT`, +`CLOCK_CONTROL_NRF_K32SRC_RC`, `SOC_DCDC_NRF52X`, `SOC_DCDC_NRF52X_HV`, `SSD1306`, `SPI`. + +One symbol to **add**: `CONFIG_PINCTRL=y` (B2). + +--- + +## 5. Magic numbers and tuned values + +Each was set against 2022 behaviour. Verdicts: + +| | Value | Where | Verdict | +|---|---|---|---| +| **M1** | *(absent)* main REG1 DC/DC | `Kconfig:3-7` | **A real four-year-old typo whose payoff turns out to be negligible. Recommendation: fix the Kconfig hygiene, do not chase the power.** `BOARD_ENABLE_DCDC`'s `depends on` names a nonexistent symbol (§2.3), so `SOC_DCDC_NRF52X` has never been selected and `nrf_power_dcdcen_set()` has never run; only the VDDH/REG0 stage was ever enabled, by hand in `board.c`. Three things bound the upside. **(a)** VDD is **1.8 V** (netlist net `+1V8`, §2.5), so REG1 steps 1.8 → 1.3 V, where the LDO is already ~72% efficient against ~85% for a buck. **(b)** Measured average draw is ~490 µA (700 mAh ÷ 2 months), of which the core rail is a fraction — the realistic saving is single-digit microamps. **(c)** `DCC`/`DEC4` are **not bonded out** of the E73 module, so whether REG1 can run in buck mode at all is unknown from this repo. **Owner decision: leave as is** — recalls doing some power validation at the time, so the current configuration is not merely accidental. Fix the `depends on` so the symbol is reachable and honest, pin `default n`, and add a comment recording that this is deliberate. Do not enable it. | +| **M2** | `BT_MAX_CONN=6` | both `*_defconfig:37` | Now redundant on the central (matches the new default) and **6× oversized on the peripheral** (new default 1). Each connection context costs RAM. Delete both lines and take the role-aware defaults; that recovers headroom for LVGL 8, which is hungrier than LVGL 7. Directly relevant to both M4 and ZMK Studio. **Owner: approved.** | +| **M3** | `ZMK_DISPLAY_DEDICATED_THREAD_STACK_SIZE=2048` | both `*_defconfig:34` | Equals the v0.3.0 default, but was sized against LVGL 7 with a 96×48 mono panel. `corneish_zen` uses **4096** for LVGL 8. **Raise to 4096 for the port**, then tune down with `CONFIG_THREAD_ANALYZER` if you care. An undersized display thread stack under LVGL 8 presents as a hard fault — exactly the symptom class you are already chasing. **Owner: approved.** | +| **M4** | LVGL heap | *not set anywhere* | The brief singled this out given the crash, and it is the most interesting gap. You never set `LVGL_MEM_SIZE`/`LV_Z_MEM_POOL_SIZE`, so LVGL has always run on its default pool. v0.3.0 defaults `LV_Z_MEM_POOL_SIZE` to 4096 **only for the built-in status screen** (`display/Kconfig`) — a custom screen gets no such default. Combined with the commit-log history (`8f58ead`/`bf0787a` mutex trouble, `e505ad1` "memory allocation bug", and the `char text[8]`→`char text[10]` fix in that same commit — a genuine buffer overflow: `"CHG:100%"` needs 9 bytes with the NUL), **memory pressure in the display path is the most plausible cause of the rare crash**. Not chased further, per the brief. But: set `CONFIG_LV_Z_MEM_POOL_SIZE` explicitly during the port, and treat the crash as a candidate to re-test afterwards rather than assume it carried over. **Owner: approved** — and this is the closest thing to a lead on the crash (O7: unreproducible in four years, roughly monthly, hoping for a passive fix). Sizing the LVGL pool explicitly, raising the display stack (M3) and reclaiming `BT_MAX_CONN` RAM (M2) are three independent bets on the same hypothesis, all free, all landing in this migration. | +| **M5** | Debounce 2 ms press / 10 ms release | both `*_defconfig:44-45` | **Deliberate and measured — preserve exactly, do not "normalise" to the 5/5 default.** Owner: tuned against the actual key switches to minimise first-press latency; the asymmetry is the point (fast to register a press, slow to release so chatter cannot double-fire). This was the very last change ever made to the config (`9e7d2f7`, 2022-03-05), which is consistent with it being the end of a tuning exercise. Symbols still exist at v0.3.0 as global overrides (default `-1` = defer to the DT `debounce-press-ms`/`debounce-release-ms`, which default to 5/5). Port the values unchanged and **add a comment recording the rationale** — this is another B12-class trap, since 2/10 looks arbitrary next to a 5/5 default. Optionally move onto the DT properties for locality; no behavioural difference. FYI only, not a recommendation: the binding documents `debounce-press-ms = 0` as "eager debouncing" (report on the first scan that sees the press), which is the theoretical latency floor — but you measured 2 ms against real switches and that beats a datasheet argument. | +| **M6** | SPI @ 8 MHz | `anaphase.dtsi:48,59` | Unchanged semantics. SSD1306 tolerates it. **Owner: leave alone.** | +| **M7** | Idle 60 s, sleep 7 200 000 ms (2 h) | both `*_defconfig:8-9` | Deliberate (`86e429d "sleep timeout 1hr"` → `9a52279`, later doubled). Symbols unchanged. Preserve. Together with idle blanking (§C5) this is what keeps the OLED — the dominant consumer — off for nearly all of the ~490 µA average. | +| **M8** | Conn interval 6–12 (7.5–15 ms) | both `*_defconfig:41-42` | **Upstream caught up with you, and brought two things you never had. Recommend deleting both lines.** Verified: 2022 ZMK set *none* of the `BT_PERIPHERAL_PREF_*` symbols (checked `app/Kconfig` at `f1b5dc4`), so your firmware ran your 6/12 on top of Zephyr's defaults for the rest. v0.3.0 sets its own (`app/Kconfig:225-235`): `MIN_INT` **6**, `MAX_INT` **12** — *identical to your hand-tuned values* — plus `PREF_LATENCY` **30** and `PREF_TIMEOUT` **400**. Zephyr's bare defaults are 24/40/**0**/**42**. So today you have latency 0 and a 420 ms supervision timeout; taking ZMK's defaults gets you **slave latency 30** at the same connection interval. That is the answer to "low latency *and* low power": with latency 30 the peripheral may skip up to 30 consecutive connection events **when it has nothing to send**, cutting idle radio wakeups by ~30×, while a keypress still transmits at the very next event — **zero added press latency**. The two go together: latency 30 requires `timeout > (1 + 30) × 15 ms × 2 ≈ 930 ms`, which is why ZMK also raises the timeout to 400 (4 s); at Zephyr's 42 (420 ms) the parameter set would be rejected. Net: delete your two lines, inherit 6/12/30/400. | +| **M9** | `CLOCK_CONTROL_NRF_K32SRC_RC=y` | both `*_defconfig:39` | **Load-bearing. Do not change. See B12.** The crystals *are* fitted (Y1/Y2, CM9V-T1A, on XL1/XL2 of both modules) and the pins are otherwise unused, so every signal available from firmware and from the parts list says "switch to `_XTAL` and reclaim power." The netlist says otherwise: each XL net has **exactly two nodes**, the SoC pin and the crystal pin — **the load caps were omitted**. Owner confirms the crystal was unstable in testing and RC was a deliberate revert. Switching to `_XTAL` leaves Zephyr waiting on an LFCLK that will not start reliably. This is the single most likely thing for a future migration to "fix" and break. Revisiting it requires a PCB revision (~9 pF per the crystal datasheet), not a config change. | + +--- + +## 6. Open questions for the owner + +Deliberately liberal, per the brief. + +**Answered during this session, recorded for the file:** + +- **O1 — the 2022 rollback recipe (deferred item, resolved for free).** `west.yml` records only + `revision: main`, so the exact build is not pinned anywhere. But it is recoverable by date: the + last commit to `zmk-config/` is `9e7d2f7`, **2022-03-05**, and `zmkfirmware/zmk` `main` at that + moment was **`f1b5dc408144e7f334bad59da6e9ac788a56e0c7`** (2022-03-05T18:01:41Z, *"fix(shields): + Proper arrow comments in reference keymap"*). That revision's `app/west.yml` pins + **`zephyr: v2.5.0+zmk-fixes`**, which implies Docker tag **`zmkfirmware/zmk-build-arm:2.5`**. + That is the whole recipe — the hard part of the deferred rollback exercise. It is worth writing + into the repo before anything else changes, because it is the only artefact standing between you + and an unbootable daily driver. +- **O2 — SWD recovery.** Confirmed accessible but requires opening the case: difficult, to be + avoided. Factored into §7 as a reason to build the rollback `.uf2` first rather than rely on SWD. +- **O3 — the custom SSD1306 driver.** Confirmed: none ever existed. §C4 closed. +- **O8 — `ZMK_DISPLAY_BLANK_ON_IDLE`.** Not a decision after all. 2022 ZMK blanked on idle + unconditionally (verified in `display/main.c` at `2128b2b`); the v0.3.0 default of `y` preserves + that. Leave it alone. §C5. +- **O9 — the M1 DC/DC fix.** Answered by the netlist plus the measured battery life: worth + single-digit µA at 1.8 V VDD, against an unverifiable module-internal precondition. Fix the + Kconfig `depends on` for hygiene; do not treat it as a power change. §5 M1. +- **O14 — why RC and not the fitted crystal.** Answered by the owner and corroborated by the + netlist: load caps were omitted from the PCB, the crystal was unstable in testing, RC was a + deliberate revert. Promoted to **B12** because it is the highest-risk "obvious fix" in the tree. +- **O15 — power baseline.** ~2 months on a 700 mAh cell (central half) ≈ 490 µA average. The OLED + dominates, and already blanks after the 60 s idle timeout. Recorded so that any future + measurement has something to compare against — and so nobody goes hunting for power savings that + are not there. + +- **O4 — `widgets/temp/`.** Not recalled, and it does not matter: the owner enumerated the live + screen contents as *connection name, pairing status, battery %, WPM, layer name* — which is + exactly the four committed widgets (output_status contributes two labels). **Nothing is missing.** + The gitignored directory held scratch, not a lost fifth widget. Closed. +- **O5 / O6 — can the peripheral show layer name or profile nickname? No, and this is unchanged at + v0.3.0.** Verified structurally rather than by inference: the split GATT service exposes exactly + six characteristics (`zmk/split/bluetooth/uuid.h`) — `POSITION_STATE`, `RUN_BEHAVIOR`, + `SENSOR_STATE`, `UPDATE_HID_INDICATORS`, `SELECT_PHYS_LAYOUT`, `INPUT_EVENT`. **None carries layer + state and none carries endpoint or profile state**, and a grep for `layer` across + `app/src/split/bluetooth/*.c` returns nothing. Corroborated by upstream's own peripheral widget: + `corneish_zen/widgets/peripheral_status.c` can display precisely one bit, + `zmk_split_bt_peripheral_is_connected()`. So the existing + `#if defined(CONFIG_ZMK_SPLIT_BLE_ROLE_CENTRAL)` → `"ANAPHASE"` fallback in `output_status.c:69-73` + remains **correct and necessary** — port it as-is. Both OLEDs confirmed working today, so "working + after" means: left shows all four, right shows connection status + battery. See §6.1 for the one + thing the right screen *could* gain. +- **O7 — crash not reproducible.** Four years, no identified trigger, roughly monthly, tolerable. + Owner is hoping for a passive fix from the upgrade. Recorded expectation: **this migration cannot + be judged on the crash** — at monthly frequency you would need ~3 months of clean running to claim + anything, and three independent memory-pressure bets land at once (M2/M3/M4), so even a genuine + fix will not be attributable. Treat any improvement as a bonus, not a success criterion. +- **O8 — display blanking.** Sufficient for power purposes; the owner notes the SSD1306 charge pump + draws negligible current from the 3.7 V input while blanked. Combined with the §C5 finding that + v0.3.0's default *preserves* 2022 behaviour, there is nothing to do. +- **O10 — `hardware/kicad_5.99/`.** Still openable, and `schematic.pdf` exists as a backup. Closed. +- **O11 — board metadata / repo structure.** Owner: skip structural risk on the first pass, but + wants redundant-file cleanup and strong section commenting. Split into two passes — see §6.1. + +**Still open — could not be verified from source (I did not build):** + +- **O12** — whether `nrf_power_dcdcen_vddh_set()` still exists in the `hal_nordic` module at the + pinned revision. Not checked — `hal_nordic` was outside the clone set. Moot if B4 is done as + recommended (delete `board.c`), which is why I did not chase it. +- **O13** — actual RAM/flash headroom after the port. LVGL 8 is larger than LVGL 7, though dropping + the WPM widget and `ZMK_WPM` claws a little back. Unknowable without a build; the first + `west build` will tell you. Relevant to the deferred ZMK Studio item. +- **O16** — whether `.yaml` is strictly required for HWMv1 board resolution at Zephyr 3.5. + I believe not. Deferred to the cleanup pass per O11. *(Renumbered from the old O11, which is now + the owner's answer above.)* + +--- + +## 6.1 Cleanup pass (owner request, second commit) + +Owner's standing annoyance: *"how separated the definitions for everything were."* Fair — the board +is currently 12 files, and the two `_defconfig`s are ~90% identical. This is deliberately staged +**after** a verified-working port, so that a functional regression and a tidying regression can +never be confused. + +**Zero-risk, do during the port:** section comments. Every value this report identifies as +deliberate-but-unexplained needs one, because the repo currently records none of them. Minimum set: +B12 (RC oscillator / missing load caps), M5 (2/10 debounce tuned to switches), M1 +(`BOARD_ENABLE_DCDC` deliberately off), B9 (`columns = <12>` works by coincidence), B1 +(`zephyr,display` chosen is what finds the panel), and the `name_array` nicknames (B13 — your table, +no upstream equivalent, do not refactor). + +**Consolidation, second pass:** + +| Change | Effect | +|---|---| +| Move the ~90% shared content out of both `*_defconfig` into `Kconfig.defconfig` | The single biggest win. Leaves each `_defconfig` holding only what genuinely differs: `BOARD_*`, split role, and the per-half widget selection. Two ~50-line files become two ~8-line files. | +| Delete `board.c`, its `zephyr_library()` block, the UF2 post-build hook, one duplicate include dir | B3, B4 — already in the main pass | +| Delete `wpm_status.{c,h}` and its Kconfig/CMake/screen references | Feature #18 | +| Delete the dead commented lines and the unreachable `menuconfig` bodies | §2.3 | +| Add `anaphase.yaml` / `anaphase.zmk.yml`, `pre_dt_board.cmake` | O16, §C2.8–9 | + +**Cannot be consolidated**, so do not spend time trying: `Kconfig`, `Kconfig.board` and +`Kconfig.defconfig` have distinct, load-bearing roles in Zephyr's board resolution and must stay +separate files; and `anaphase_left.keymap` / `anaphase_right.keymap` must exist as one-line includes +because ZMK resolves the keymap by `.keymap`. Twelve files will become about eight — better, +but the floor is set by Zephyr, not by 2022-you. + +--- + +## 7. File-by-file change list + +Ordered so that the build gets further with each step. Nothing below has been applied. + +| File | Change | Why | Est. | +|---|---|---|---| +| `zmk-config/west.yml` | `revision: main` → `v0.3.0` | Pin the target. Nothing else works until this is done. | 2 min | +| `build.yaml` *(new, repo root)* | Matrix with `anaphase_left`, `anaphase_right` | §C1 | 5 min | +| `.github/workflows/build.yml` *(new)* | Reusable workflow, `config_path: zmk-config`, pinned `@v0.3.0` | §C1 | 10 min | +| `boards/arm/anaphase/anaphase.dtsi` | Add `zephyr,display = &oled;` to `chosen` | **B1 — blocks compile** | 5 min | +| `boards/arm/anaphase/anaphase.dtsi` | Replace `sck-pin`/`mosi-pin`/`miso-pin`/`miso-pull-down` with `pinctrl-0`/`-1`/`-names` | **B2 — blocks DT** | 45 min | +| `boards/arm/anaphase-pinctrl.dtsi` *(new)* | `spi0_default` / `spi0_sleep` groups, SCK P0.08, MOSI P0.06 | B2 | (above) | +| `boards/arm/anaphase/anaphase.dtsi` | Drop three `label` properties | B1/§C2.5, cosmetic — do last | 5 min | +| `boards/arm/anaphase/anaphase_left.dts`, `_right.dts` | Add `wakeup-source;`, drop `label = "KSCAN"` | §C2.4 | 10 min | +| `boards/arm/anaphase/Kconfig` | Fix `depends on` to `(BOARD_ANAPHASE_LEFT \|\| BOARD_ANAPHASE_RIGHT)`; add `BOARD_ENABLE_DCDC_HV` (`default y`, preserves `board.c` behaviour). Leave `BOARD_ENABLE_DCDC` at `default n` unless you specifically want the M1 experiment | B4, M1 | 15 min | +| `boards/arm/anaphase/board.c` | **Delete** | B4 — superseded and won't compile | 2 min | +| `boards/arm/anaphase/CMakeLists.txt` | Delete `zephyr_library()` + `board.c` line + UF2 post-build block + the `wpm_status.c` line entirely; drop one duplicate include dir | B3, B4, B8, #18 | 15 min | +| `boards/arm/anaphase/Kconfig.defconfig` | Rename all 14 LVGL symbols; restructure the two font choices; drop `CUSTOM_WIDGET_WPM_STATUS` and `select ZMK_WPM` | **§C6 — largest config item**, #18 | 45 min | +| `boards/arm/anaphase/anaphase_left_defconfig` | `LVGL_*`→`LV_*`; add `PINCTRL=y`, `BUILD_OUTPUT_UF2=y`; **drop `BT_MAX_CONN` and both `BT_PERIPHERAL_PREF_*` lines** (inherit 6/12/30/400); raise display stack to 4096; add `LV_Z_MEM_POOL_SIZE`; drop the two WPM symbols; delete dead comments. **Keep `K32SRC_RC` and both debounce values, each with a comment saying why** | §C6, M2, M3, M4, **M5, M8, B12**, #18 | 30 min | +| `boards/arm/anaphase/anaphase_right_defconfig` | Same, plus delete the dead `ZMK_SPLIT_BLE_ROLE_PERIPHERAL` line | §C6, M2, **M5, M8, B12** | 20 min | +| `boards/arm/anaphase/custom_status_screen.c` | LVGL 8 constructors/align/style; `zephyr/` includes; drop `lv_refr_now`; remove the WPM widget and decide whether to re-space the remaining four rows (currently y = 1/11/21/31/41; dropping WPM frees y=31) | B5, #18 | 1 h | +| `boards/arm/anaphase/widgets/battery_status.c` | LVGL 8; `zephyr/` includes; drop `src/lv_themes/lv_theme.h` | B5 | 45 min | +| `boards/arm/anaphase/widgets/output_status.c` | LVGL 8; `zephyr/` includes; **endpoint API migration**; `#if` symbol decision. **Preserve the nickname table.** | B5, B6, §C6 | 1.5 h | +| `boards/arm/anaphase/widgets/layer_status.c` | LVGL 8; `zephyr/` includes; `zmk_keymap_layer_name(zmk_keymap_layer_index_to_id(i))`; `zmk_keymap_layer_index_t` | B5, B7 | 45 min | +| `boards/arm/anaphase/widgets/wpm_status.{c,h}` | **Delete** — not ported | #18 | 2 min | +| `boards/arm/anaphase/widgets/*.h` (×3, WPM deleted) | `` → `` | B5 | 5 min | +| `boards/arm/anaphase/anaphase.keymap` | `label` → `display-name` on 5 layers *(optional)* | B7 | 10 min | +| `boards/arm/anaphase/pre_dt_board.cmake` *(new)* | Copy from `corneish_zen` | §C2.8, cosmetic | 5 min | +| `boards/arm/anaphase/anaphase.yaml`, `anaphase.zmk.yml` *(new)* | Board metadata | §C2.9 | 15 min | +| `README.md` | Record the O1 rollback recipe and the build command | O1 — do this **first** | 15 min | + +**Untouched:** `anaphase_left.keymap`, `anaphase_right.keymap` (one-line includes), `Kconfig.board`, +`board.cmake` (both runner includes verified present at `zephyr/boards/common/`), `LICENSE`, +everything under `hardware/`. + +--- + +## 8. Effort estimate + +| Subsystem | Risk | Estimate | +|---|---|---| +| C1 Build system | minor | **0.5–1 h** | +| C2 Board definition (pinctrl, chosen, DC/DC, kscan) | substantial | **2–3 h** | +| C3 Keymap | clean | **0–0.5 h** (optional `display-name` only) | +| C4 SSD1306 driver | n/a | **0 h** | +| C5 LVGL 7→8 widget port (3 widgets + screen; WPM retired) | substantial | **4.5–6 h** | +| C6 Kconfig symbol migration | minor | **1.5–2 h** | +| **Subtotal, code** | | **8.5–12.5 h** | +| First successful build + iteration | | **2–4 h** | +| On-hardware verification (26 features, 2 halves) | | **2–3 h** | +| **Total** | | **12–19 h** | +| *Cleanup pass (§6.1), separate commit* | | *+2–3 h* | + +Excludes: rebuilding the 2022 rollback `.uf2` (**2–4 h** on its own — a local Docker build against +`zmkfirmware/zmk-build-arm:2.5`, now unblocked by O1) and chasing the crash. + +**Sequencing recommendation.** Given O2 — SWD requires opening the case — do the rollback `.uf2` +first. It is the only cheap insurance available, the recipe is now known, and it converts "brick the +daily driver" from a case-opening incident into a drag-and-drop. Then: build system → board DT → +Kconfig → widgets, verifying compile at each step. + +**Change nothing about power.** The two candidates were both run to ground: M9/B12 is a deliberate +workaround for missing load caps and will break the boot if "fixed", and M1 is worth single-digit +microamps against a measured ~490 µA baseline. Fix M1's `depends on` for Kconfig hygiene, leave +`BOARD_ENABLE_DCDC` at `default n`, and keep the RC oscillator. The goal of this migration is a +firmware that behaves identically to the one running today; power work is a separate project and +would need a PCB revision to be worth anything. + +--- + +## Appendix — v0.4 / `main` (Zephyr 4.1) scope + +Estimated, not planned. Sized on top of a completed v0.3.0 migration. Verified: +`zmkfirmware/zmk` `main`'s `app/west.yml` pins `zephyr: v4.1.0+zmk-fixes`. + +| Item | Scope | Est. | +|---|---|---| +| **HWMv2 board migration** | `boards/arm/anaphase/` → `boards/reversebias/anaphase/`; add `board.yml` declaring the board and its two variants; `Kconfig.board` → `Kconfig.anaphase`; `*_defconfig` restructured; **board IDs gain SoC qualifiers** (`anaphase_left` → `anaphase/nrf52840` form), which means `build.yaml` and any flashing scripts change too | **3–5 h** | +| **LVGL 8.3 → 9.3** | `lv_obj_add_event_cb` → `lv_obj_add_event`; `LV_IMG_CF_*` → `LV_COLOR_FORMAT_*`; reworked display-object API; `lv_disp_t` → `lv_display_t`. Your widgets are label-only, which keeps this at the low end — no images, no custom draw | **3–4 h** | +| **SSD1306 → MIPI DBI** | The migration the brief anticipated, arriving one target late. Panel becomes a child of a `zephyr,mipi-dbi-spi` bus node rather than of `&spi0` directly. Mechanical, but must be validated on hardware | **1–2 h** | +| **Zephyr 3.5→4.1 drift** | Two years of deprecations beyond the ones catalogued here. Unknown until attempted | **2–4 h** | +| **Total** | | **9–15 h** | + +Recommendation stands: v0.3.0 first, on its own, verified working, then reassess. The v0.4 delta is +roughly as large as this entire migration, and doing both at once means any regression has twenty +candidate causes instead of one. diff --git a/zmk-upgrade-assessment.md b/zmk-upgrade-assessment.md new file mode 100644 index 0000000..a281c84 --- /dev/null +++ b/zmk-upgrade-assessment.md @@ -0,0 +1,306 @@ +# ZMK Portability Assessment — Custom Split Wireless Board (2022 → v0.3.0) + +## Purpose + +This is the brief for an **assessment session**. The output is a written report. +Do not modify, migrate, or "fix" anything during this pass. + +Two questions to answer: + +1. **What does this firmware actually do?** Four years on, the source is the only + record. Every capability must be catalogued before anything is touched. +2. **What in here is a bodge, and is it still needed?** A 2022 ZMK config for + custom hardware is largely workarounds. Some are now redundant, some are + load-bearing, and telling them apart is most of the value of this exercise. + +The keyboard is a daily-driver with no archived `.uf2`. A change that silently +drops a feature will not be noticed until the feature is needed. + +--- + +## 1. Context + +**Hardware:** fully custom split wireless keyboard. Non-standard nRF52840 modules +— not a nice!nano, Xiao, or any off-the-shelf controller. Defined in ZMK as a +**board**, not a shield. Two SPI-driven SSD1306 OLEDs. Maltron-derived layout with +combination layers (mod1 → L1, mod2 → L2, mod1+mod2 → L3, i.e. `conditional_layers`). + +**Software:** `zmk-config` last modified March 2022. At that time the tree was +pre-Zephyr-3.0 and ZMK had no numbered releases at all — everyone tracked `main`, +and the first tag, v0.1.0, did not appear until 2024-11-29. A hand-written SPI +driver for the SSD1306 was required because mainline Zephyr's was I²C-only. A set +of custom LVGL widgets exists. + +**Known defect:** rare crash requiring a hardware reset. Out of scope for this +report except where the excavation turns up a plausible cause — if it does, note +it, don't chase it. + +**Target:** + +| | Version | Zephyr | Notes | +|---|---|---|---| +| **Target** | **v0.3.0** (2025-08-01) | 3.5 | Latest numbered release. HWMv1, LVGL 8.x. | +| Not now | `main` / v0.4 | 4.1 | HWMv2 + LVGL 9.3, both breaking | + +Pin `config/west.yml` to `v0.3.0`. This decision is made — do not relitigate it, +but do size the v0.4 delta as an appendix so it can be decided later on real +numbers. + +--- + +## 2. Ground rules + +1. **Read-only pass.** No edits, no branches, no "quick fixes." +2. **Every finding gets a status.** "Probably unused" is not a status. + "Present in source, purpose undetermined, needs owner input" is. +3. **Verify upstream claims against the actual tree** at the pinned revision — + driver bindings, Kconfig symbol names, LVGL APIs. Where verification isn't + possible, say so explicitly rather than asserting from memory. +4. **Preserve provenance.** When reporting that something can be deleted, state + what replaces it and how that was confirmed. + +--- + +## Part A — Feature excavation + +The primary deliverable. Walk the whole repository and build a table: + +| Feature | Defined in | Depends on | v0.3.0 risk | Status | +|---|---|---|---|---| + +### Sources to mine + +**`config/*.keymap`** — every layer and how it's reached; every binding; +`conditional_layers` definitions; combos; macros; custom behaviors (hold-taps, +mod-morphs, tap-dances, sticky keys); encoder/sensor bindings; `&bootloader`, +`&sys_reset`, `&bt` bindings. + +**`config/*.conf`** — every non-default `CONFIG_` line is a past decision. +Produce a line-by-line verdict: does the symbol still exist at v0.3.0, is the +default now different, and what was it presumably set for. Pay attention to +timeouts, sleep behaviour, BLE settings, display config, and log levels. + +**Board definition** (`config/boards/…` or a `boards/` root) — `.dts`, `.dtsi`, +`_defconfig`, `Kconfig`, `Kconfig.board`, `Kconfig.defconfig`, `board.cmake`, +`.overlay`, `.yaml`. Inventory every file and what it configures: kscan +(matrix/direct, GPIO flags, `diode-direction`), split role assignment, SPI bus +and display GPIOs (reset, DC, CS), power (DC-DC, LDO, battery divider), pin +mappings. + +**Display widgets** — each widget, what event it subscribes to, what it renders. + +**Anything else in the tree.** Scripts, CI files, notes, README fragments, +`.gitignore` entries that hint at deleted artifacts. + +### Dead code check + +Flag anything defined but never referenced: layers with no way in, behaviors +never bound, Kconfig symbols for features not present, widgets not registered. +Report separately from live features — these are candidates for deletion, but the +owner decides. + +--- + +## Part B — Bodge archaeology + +The interesting half. For each workaround found, classify it: + +| Class | Meaning | Action | +|---|---|---| +| **Obsolete** | Upstream now does this natively | Delete, cite the replacement | +| **Superseded** | Upstream does it differently | Migrate to the upstream mechanism | +| **Still required** | No upstream equivalent | Port as-is | +| **Unknown** | Purpose undetermined | Escalate to owner | + +For every item, state **what breaks if it's dropped**. That's the sentence that +prevents a silent regression. + +### Where to dig + +**`config/west.yml` — check this first, it is the highest-value single file.** +Does it point at `zmkfirmware/zmk`, or at a **fork**? Are there extra `projects` +entries? In 2022 it was common to build against a personal or community fork to +get unmerged features. If this config does, every patch in that fork is an +out-of-tree dependency that must be re-homed, and identifying them is a large +part of the migration scope. + +A concrete example worth checking for: mouse keys existed only as a long-running +PR in 2022 and people forked to get them. Mouse move and scroll are now upstream +as of v0.2.0, so such a fork would be **Obsolete** — but note the API changed +between the old PR and the merged version (`&mwh` became `&msc`, and the +`MOVE_VERT`/`MOVE_HOR`/`SCROLL_*` constants were replaced), so any keymap +bindings using the old names need updating. + +**Shadowed upstream files.** Anything in this repo occupying a path that also +exists upstream is an override. Identify each, and where feasible diff against +the upstream version of that era to isolate exactly what was changed and why. + +**Build system hooks.** Root `CMakeLists.txt`, `Kconfig`, `zephyr/module.yml`, +`module.yml`. These declare out-of-tree drivers and modules — the SSD1306 driver +is presumably wired in this way. Also check for `.patch` / `.diff` files and any +script that applies them. + +**Comment scan.** Grep the tree for `HACK`, `FIXME`, `TODO`, `XXX`, `WORKAROUND`, +`temporary`, `don't remove`, `broken`, `upstream`. 2022-you left notes; read them. + +**Git history.** `git log` over the config and board files. Commit messages are +often the only surviving explanation for why a value is what it is. Note any +commit that looks like a bug workaround, and check whether the underlying bug was +since fixed upstream. + +**Magic numbers.** Debounce values, timeouts, poll intervals, stack sizes, heap +sizes set away from defaults. Each one was tuned against 2022 behaviour and may +now be wrong in either direction. LVGL heap sizing is especially worth flagging +given the crash symptom. + +--- + +## Part C — Portability verdict by subsystem + +For each, give a risk rating (**clean / minor / substantial / unknown**), the +specific changes required, and the evidence. + +### C1. Build system — expected: minor + +The 2022 workflow will not run on current Actions. Required: + +- `.github/workflows/build.yml` → call the shared reusable workflow: + ```yaml + on: [push, pull_request, workflow_dispatch] + jobs: + build: + uses: zmkfirmware/zmk/.github/workflows/build-user-config.yml@main + ``` +- `build.yaml` at repo root — the board/shield matrix. Did not exist in the 2022 + layout; report the correct board identifiers derived from the actual board + directory names. +- `config/west.yml` — pin ZMK revision to `v0.3.0`, and resolve any fork found + in Part B. + +Board IDs do **not** gain SoC qualifiers until Zephyr 4.1, so plain board names +remain correct at v0.3.0. + +### C2. Board definition — expected: minor to substantial + +HWMv1 survives through Zephyr 3.6, so the existing directory structure stays +valid at v0.3.0. No `board.yml`, no restructuring. The work is in drift: + +- GPIO flag and binding forms changed across Zephyr 3.0→3.5 — identify anything + using deprecated syntax +- kscan properties: `wakeup-source`, `diode-direction`, row/col GPIO ordering +- Kconfig symbols in `_defconfig` that no longer exist or changed default +- **nRF52840 high-voltage DC-DC**: v0.3.0 changed the default. Check whether this + board sets it explicitly; if it relies on the old default, behaviour changes +- Split role assignment — report whether this uses two board definitions or one + with variants + +### C3. Keymap — expected: clean + +Confirm rather than assume, but the syntax has been stable. `&kp`, `&mo`, `<`, +`&mt`, `&trans`, `&none`, `&bt` unchanged. `conditional_layers` unchanged, so the +mod1+mod2 → L3 arrangement should port untouched. + +Specific checks: +- Custom hold-tap definitions — new properties exist (`require-prior-idle-ms`, + `hold-trigger-on-release`) but nothing was removed. Report these as *available + improvements*, not required changes. +- Deprecated combo Kconfigs — v0.3.0 restored prompts for two of these, implying + a deprecation path. Check whether this config uses them. +- Parametrised macros — behaviour was fixed in v0.3.0, so semantics may differ + subtly from 2022 if any are used. +- Any binding using names from a forked/unmerged feature (see the mouse-key note + in Part B). + +### C4. SSD1306 SPI driver — expected: obsolete, but verify + +The custom driver may be deletable. Zephyr's display drivers were migrated onto +the **MIPI DBI** abstraction, which handles 4-wire SPI, and the SSD1306 driver +went with it. + +**Verify against the actual Zephyr tree at 3.5 — do not take this on trust.** + +- Does the `solomon,ssd1306` / `solomon,ssd1306fb` binding at Zephyr 3.5 support + an SPI/MIPI-DBI bus? +- What devicetree structure is required — a `zephyr,mipi-dbi-spi` bus node with + the panel as a child? +- **Feature parity:** does the custom driver do anything mainline doesn't? + Unusual init sequence, charge-pump or contrast handling, partial refresh, + rotation, non-standard resolution or offset. This is the deciding question. + +Report both paths — (a) delete and use mainline, (b) keep and fix up — with a +recommendation. Path (a) is strongly preferred *if* parity holds, since it +retires the driver permanently. Parity must be demonstrated. + +### C5. LVGL widgets — expected: minor at v0.3.0 + +Determine the actual version delta: identify the LVGL version in the old tree and +at v0.3.0 (8.x). If both are 8.x, expect minor churn. If the old code is 7.x, +expect a real port — styles, object API, and event registration all changed at +7→8. + +Also check whether mainline now covers anything the widgets hand-roll. v0.2.0 +added display on/off pin support, a configurable display update period, and a fix +to only default to the mono theme at 1bpp — all plausibly hand-implemented in a +2022 custom widget set. + +Size the **LVGL 9.3** port separately as v0.4 appendix material: +`lv_obj_add_event_cb` → `lv_obj_add_event`, `LV_IMG_CF_*` → `LV_COLOR_FORMAT_*`, +reworked display object API. + +### C6. `.conf` symbol audit — expected: minor + +Line-by-line verdict on every `CONFIG_` set in the tree. Three outcomes per line: +still valid, renamed/moved (give the new name), or removed (say what replaces the +behaviour). + +--- + +## Part D — Report format + +One markdown document: + +1. **Executive summary** — overall risk, and the two or three things that will + actually consume the time +2. **Feature inventory table** (Part A) — the primary deliverable +3. **Bodge register** (Part B) — each item classified, with "what breaks if + dropped" +4. **Subsystem verdicts** (Part C) with risk ratings +5. **File-by-file change list** — every file needing edits, with the reason +6. **Open questions for the owner** — anything undetermined from context, and + anything where upstream behaviour couldn't be verified. Be liberal here; + a question is cheaper than a wrong assumption. +7. **Effort estimate** per subsystem +8. **Appendix: v0.4 / Zephyr 4.1 scope** — HWMv2 board migration and LVGL 9.3 + port, estimated but not planned + +--- + +## Deferred — do not address in this pass + +Noted so they aren't lost, and so the assessment doesn't drift into them. + +- **Rebuilding a 2022 rollback `.uf2`.** Separate exercise. CI can't do it (the + shared workflow pulls a modern Docker image; the old tree needs a 2022 + toolchain), so it means a local Docker build against a pinned old + `zmk-build-arm` tag. If Part B happens to surface the exact ZMK revision or + Docker tag the 2022 build used, **record it** — that's the hard part of that + job and it's free to note in passing. +- **ZMK Studio.** Available at v0.3.0. Needs a physical layout with the `keys` + property, removal of any chosen `zmk,matrix-transform`, CDC-ACM console config + on the board, a `&studio_unlock` binding, and headroom in RAM. If the + excavation reveals whether a KLE or QMK JSON version of this layout exists + anywhere, note it — that determines whether the physical layout is an import or + a from-scratch job. +- **Mouse keys.** Upstream as of v0.2.0. +- **Ctrl+Backspace → word delete** via mod-morph: + ```dts + ctrl_bspc: ctrl_bspc { + compatible = "zmk,behavior-mod-morph"; + #binding-cells = <0>; + bindings = <&kp BSPC>, <&kp LA(BSPC)>; + mods = <(MOD_LCTL|MOD_RCTL)>; + }; + ``` +- **Watchdog and crash instrumentation.** After a working baseline exists. + +Each of these added during migration makes a regression harder to attribute.