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.