From 72b18d70264ec7d677c3f52900f608552e13906c Mon Sep 17 00:00:00 2001 From: reversebias Date: Sun, 9 Aug 2026 22:01:17 +1000 Subject: [PATCH 1/5] Relay layer + profile status to the peripheral display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The right half cannot see layer or BLE-profile state (endpoints.c/ble.c/ keymap.c are central-only at v0.3.0 and the split GATT service has no such characteristic - see zmk-next-steps.md §3). Instead of new GATT, the central pushes a snapshot over the stock RUN_BEHAVIOR channel by invoking a carrier behavior on the peripheral, the same mechanism &bootloader locality uses. - stat_rly behavior (anaphase,behavior-status-relay): node name kept to 8 chars to fit the 9-byte behavior_dev split payload field (cf. upstream's 'bootload'/'sysreset' naming). Never bound in a keymap. - status_relay.c: central listens to layer_state_changed + ble_active_profile_changed and sends (layer id, profile index); a 15s refresh timer heals stale displays after split reconnects. Peripheral side stores the snapshot and raises a local event. - layer_status widget: peripheral branch renders the relayed layer id using a name table generated from this half's own keymap devicetree (present even though keymap.c isn't compiled). '----' until first relay. Studio runtime renames won't propagate (DT names only). - output_status widget: peripheral name row now shows the central's active profile nickname (name_array moved to shared scope); 'ANAPHASE' fallback until the relay lands. Split-link PAIRED/WAITING unchanged. - Gated behind CONFIG_ANAPHASE_STATUS_RELAY (default y) - one line to disable if the relay ever misbehaves. Known risk (accepted): zmk_split_central_invoke_behavior is exported but undocumented; the v0.4 split-transport rework will likely force a re-port. Cost: +320B flash central, +740B peripheral. Co-Authored-By: Claude Fable 5 --- zmk-config/boards/arm/anaphase/CMakeLists.txt | 2 + .../boards/arm/anaphase/Kconfig.defconfig | 7 + zmk-config/boards/arm/anaphase/anaphase.dtsi | 11 ++ .../arm/anaphase/anaphase_right_defconfig | 5 +- .../arm/anaphase/peripheral_status_relay.h | 22 +++ zmk-config/boards/arm/anaphase/status_relay.c | 144 ++++++++++++++++++ .../arm/anaphase/widgets/layer_status.c | 60 +++++++- .../arm/anaphase/widgets/output_status.c | 45 ++++-- .../anaphase,behavior-status-relay.yaml | 12 ++ 9 files changed, 289 insertions(+), 19 deletions(-) create mode 100644 zmk-config/boards/arm/anaphase/peripheral_status_relay.h create mode 100644 zmk-config/boards/arm/anaphase/status_relay.c create mode 100644 zmk-config/dts/bindings/behaviors/anaphase,behavior-status-relay.yaml diff --git a/zmk-config/boards/arm/anaphase/CMakeLists.txt b/zmk-config/boards/arm/anaphase/CMakeLists.txt index 43ee667..baf20a3 100644 --- a/zmk-config/boards/arm/anaphase/CMakeLists.txt +++ b/zmk-config/boards/arm/anaphase/CMakeLists.txt @@ -5,6 +5,8 @@ # replacing the old hand-rolled uf2conv.py post-build hook. # DC/DC setup moved from board.c to Kconfig (SOC_DCDC_NRF52X_HV). +target_sources_ifdef(CONFIG_ANAPHASE_STATUS_RELAY app PRIVATE status_relay.c) + 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) diff --git a/zmk-config/boards/arm/anaphase/Kconfig.defconfig b/zmk-config/boards/arm/anaphase/Kconfig.defconfig index 69e6e1f..444ba22 100644 --- a/zmk-config/boards/arm/anaphase/Kconfig.defconfig +++ b/zmk-config/boards/arm/anaphase/Kconfig.defconfig @@ -193,4 +193,11 @@ config CUSTOM_WIDGET_OUTPUT_STATUS config CUSTOM_WIDGET_LAYER_STATUS bool "custom layer status widget" +# Central pushes layer id + active profile index to the peripheral display +# over the stock split RUN_BEHAVIOR channel (status_relay.c). Rides an +# undocumented-but-exported API; see zmk-next-steps.md §3 for the risk notes. +config ANAPHASE_STATUS_RELAY + bool "relay layer/profile status to the peripheral display" + default y + endif # BOARD_ANAPHASE_LEFT || BOARD_ANAPHASE_RIGHT diff --git a/zmk-config/boards/arm/anaphase/anaphase.dtsi b/zmk-config/boards/arm/anaphase/anaphase.dtsi index f98f0ab..e95e746 100644 --- a/zmk-config/boards/arm/anaphase/anaphase.dtsi +++ b/zmk-config/boards/arm/anaphase/anaphase.dtsi @@ -104,6 +104,17 @@ vbatt: vbatt { compatible = "zmk,battery-nrf-vddh"; }; + + behaviors { + // Carrier for the peripheral status relay (status_relay.c). Node + // name must stay <= 8 chars: it travels in the 9-byte behavior_dev + // field of the split RUN_BEHAVIOR payload (cf. upstream "bootload"/ + // "sysreset"). Never bind this in a keymap. + stat_rly: stat_rly { + compatible = "anaphase,behavior-status-relay"; + #binding-cells = <2>; + }; + }; }; &spi0 { diff --git a/zmk-config/boards/arm/anaphase/anaphase_right_defconfig b/zmk-config/boards/arm/anaphase/anaphase_right_defconfig index 5d121af..2af9119 100644 --- a/zmk-config/boards/arm/anaphase/anaphase_right_defconfig +++ b/zmk-config/boards/arm/anaphase/anaphase_right_defconfig @@ -41,7 +41,8 @@ CONFIG_ZMK_DISPLAY_STATUS_SCREEN_CUSTOM=y CONFIG_ZMK_LV_FONT_DEFAULT_SMALL_UNSCII_8=y # --- per-half widget selection --- -# Peripherals cannot see layer/endpoint state over the split GATT service, -# so the right half shows split-link status + battery only. +# Layer name + profile nickname arrive from the central via the status +# relay (ANAPHASE_STATUS_RELAY); split-link status + battery are local. CONFIG_CUSTOM_WIDGET_BATTERY_STATUS=y CONFIG_CUSTOM_WIDGET_OUTPUT_STATUS=y +CONFIG_CUSTOM_WIDGET_LAYER_STATUS=y diff --git a/zmk-config/boards/arm/anaphase/peripheral_status_relay.h b/zmk-config/boards/arm/anaphase/peripheral_status_relay.h new file mode 100644 index 0000000..abb2720 --- /dev/null +++ b/zmk-config/boards/arm/anaphase/peripheral_status_relay.h @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 + * + * SPDX-License-Identifier: MIT + */ + +#pragma once + +#include + +// Raised on the peripheral when the central relays fresh status. +struct zmk_peripheral_status_relay_changed { + uint8_t layer_id; + uint8_t profile_index; +}; + +ZMK_EVENT_DECLARE(zmk_peripheral_status_relay_changed); + +// Last relayed state; valid is false until the first relay arrives after boot. +bool peripheral_status_relay_valid(void); +uint8_t peripheral_status_relay_layer_id(void); +uint8_t peripheral_status_relay_profile_index(void); diff --git a/zmk-config/boards/arm/anaphase/status_relay.c b/zmk-config/boards/arm/anaphase/status_relay.c new file mode 100644 index 0000000..a5b9050 --- /dev/null +++ b/zmk-config/boards/arm/anaphase/status_relay.c @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2026 + * + * SPDX-License-Identifier: MIT + * + * Peripheral status relay. The right half cannot see layer or BLE-profile + * state (endpoints.c/ble.c/keymap.c are central-only at ZMK v0.3.0, and the + * split GATT service carries no such characteristic), so the central pushes + * a snapshot over the stock RUN_BEHAVIOR channel by "invoking" the stat_rly + * behavior on the peripheral — the same mechanism &bootloader locality uses. + * + * Wire format: behavior_dev = "stat_rly" (DT node name, <= 8 chars to fit + * the 9-byte split payload field), param1 = layer id, param2 = profile index. + * + * Known limits (accepted, see zmk-next-steps.md §3): + * - zmk_split_central_invoke_behavior is exported but undocumented; the + * v0.4 split-transport rework will likely require a re-port. + * - Updates sent while the halves are disconnected are lost; a slow refresh + * timer (and every layer change) self-heals the stale display. + */ + +#include +#include +LOG_MODULE_DECLARE(zmk, CONFIG_ZMK_LOG_LEVEL); + +#include +#include + +#if IS_ENABLED(CONFIG_ZMK_SPLIT_ROLE_CENTRAL) + +/* ------------------------------------------------------------------ */ +/* Central: watch layer/profile state, push snapshots to peripherals. */ +/* ------------------------------------------------------------------ */ + +#include +#include +#include +#include +#include + +#define RELAY_REFRESH_INTERVAL K_SECONDS(15) + +static void send_status(struct k_work *work) { + struct zmk_behavior_binding binding = { + .behavior_dev = "stat_rly", + .param1 = zmk_keymap_layer_index_to_id(zmk_keymap_highest_layer_active()), + .param2 = zmk_ble_active_profile_index(), + }; + struct zmk_behavior_binding_event event = { + .position = 0, + .timestamp = k_uptime_get(), + }; + + for (int i = 0; i < ZMK_SPLIT_CENTRAL_PERIPHERAL_COUNT; i++) { + // Best-effort: fails harmlessly (-ENODEV/-ENOTCONN) while the + // peripheral is away; the refresh timer catches it up on reconnect. + zmk_split_central_invoke_behavior(i, &binding, event, true); + } +} + +static K_WORK_DELAYABLE_DEFINE(relay_work, send_status); + +static void schedule_send(void) { k_work_reschedule(&relay_work, K_MSEC(20)); } + +static int status_relay_listener(const zmk_event_t *eh) { + schedule_send(); + return ZMK_EV_EVENT_BUBBLE; +} + +ZMK_LISTENER(status_relay, status_relay_listener); +ZMK_SUBSCRIPTION(status_relay, zmk_layer_state_changed); +ZMK_SUBSCRIPTION(status_relay, zmk_ble_active_profile_changed); + +static void relay_refresh(struct k_work *work) { + send_status(work); + k_work_schedule(k_work_delayable_from_work(work), RELAY_REFRESH_INTERVAL); +} + +static K_WORK_DELAYABLE_DEFINE(relay_refresh_work, relay_refresh); + +static int status_relay_init(void) { + k_work_schedule(&relay_refresh_work, RELAY_REFRESH_INTERVAL); + return 0; +} + +SYS_INIT(status_relay_init, APPLICATION, CONFIG_APPLICATION_INIT_PRIORITY); + +#else /* peripheral */ + +/* ------------------------------------------------------------------ */ +/* Peripheral: the stat_rly behavior stores the snapshot and raises a */ +/* local event for the display widgets. */ +/* ------------------------------------------------------------------ */ + +#define DT_DRV_COMPAT anaphase_behavior_status_relay + +#include +#include +#include "peripheral_status_relay.h" + +#if DT_HAS_COMPAT_STATUS_OKAY(DT_DRV_COMPAT) + +static struct { + uint8_t layer_id; + uint8_t profile_index; + bool valid; +} relay_state; + +bool peripheral_status_relay_valid(void) { return relay_state.valid; } +uint8_t peripheral_status_relay_layer_id(void) { return relay_state.layer_id; } +uint8_t peripheral_status_relay_profile_index(void) { return relay_state.profile_index; } + +static int on_keymap_binding_pressed(struct zmk_behavior_binding *binding, + struct zmk_behavior_binding_event event) { + relay_state.layer_id = binding->param1; + relay_state.profile_index = binding->param2; + relay_state.valid = true; + + raise_zmk_peripheral_status_relay_changed((struct zmk_peripheral_status_relay_changed){ + .layer_id = relay_state.layer_id, + .profile_index = relay_state.profile_index, + }); + + return ZMK_BEHAVIOR_OPAQUE; +} + +static int on_keymap_binding_released(struct zmk_behavior_binding *binding, + struct zmk_behavior_binding_event event) { + return ZMK_BEHAVIOR_OPAQUE; +} + +static const struct behavior_driver_api status_relay_driver_api = { + .binding_pressed = on_keymap_binding_pressed, + .binding_released = on_keymap_binding_released, +}; + +BEHAVIOR_DT_INST_DEFINE(0, NULL, NULL, NULL, NULL, POST_KERNEL, + CONFIG_KERNEL_INIT_PRIORITY_DEFAULT, &status_relay_driver_api); + +#endif /* DT_HAS_COMPAT_STATUS_OKAY */ + +ZMK_EVENT_IMPL(zmk_peripheral_status_relay_changed); + +#endif /* IS_ENABLED(CONFIG_ZMK_SPLIT_ROLE_CENTRAL) */ diff --git a/zmk-config/boards/arm/anaphase/widgets/layer_status.c b/zmk-config/boards/arm/anaphase/widgets/layer_status.c index bfeb6b2..3bb6f5a 100644 --- a/zmk-config/boards/arm/anaphase/widgets/layer_status.c +++ b/zmk-config/boards/arm/anaphase/widgets/layer_status.c @@ -10,12 +10,17 @@ LOG_MODULE_DECLARE(zmk, CONFIG_ZMK_LOG_LEVEL); #include #include "layer_status.h" -#include #include -#include static sys_slist_t widgets = SYS_SLIST_STATIC_INIT(&widgets); +#if IS_ENABLED(CONFIG_ZMK_SPLIT_ROLE_CENTRAL) + +/* Central: layer state comes from the local keymap. */ + +#include +#include + struct layer_status_state { zmk_keymap_layer_index_t index; const char *label; @@ -46,6 +51,57 @@ ZMK_DISPLAY_WIDGET_LISTENER(widget_layer_status, struct layer_status_state, laye ZMK_SUBSCRIPTION(widget_layer_status, zmk_layer_state_changed); +#else /* peripheral */ + +/* + * Peripheral: layer state arrives via the status relay (status_relay.c). + * Names come from this half's own copy of the keymap devicetree — the DT + * is present even though keymap.c isn't compiled on peripherals. Indexed + * by layer id (== DT child order), which is what the relay carries. + * Studio runtime *renames* on the central won't propagate here (DT names + * only); Studio layer edits still show the right layer id. + */ + +#include "../peripheral_status_relay.h" + +#define DT_DRV_COMPAT zmk_keymap +#define LAYER_NAME_ENTRY(node) DT_PROP_OR(node, display_name, DT_PROP_OR(node, label, "")), + +static const char *layer_names[] = {DT_INST_FOREACH_CHILD(0, LAYER_NAME_ENTRY)}; + +struct layer_status_state { + uint8_t layer_id; + bool valid; +}; + +static void set_layer_symbol(lv_obj_t *label, struct layer_status_state state) { + if (!state.valid || state.layer_id >= ARRAY_SIZE(layer_names) || + strlen(layer_names[state.layer_id]) == 0) { + lv_label_set_text(label, "----"); + } else { + lv_label_set_text(label, layer_names[state.layer_id]); + } +} + +static void layer_status_update_cb(struct layer_status_state state) { + struct zmk_widget_layer_status *widget; + SYS_SLIST_FOR_EACH_CONTAINER(&widgets, widget, node) { set_layer_symbol(widget->obj, state); } +} + +static struct layer_status_state layer_status_get_state(const zmk_event_t *eh) { + return (struct layer_status_state){ + .layer_id = peripheral_status_relay_layer_id(), + .valid = peripheral_status_relay_valid(), + }; +} + +ZMK_DISPLAY_WIDGET_LISTENER(widget_layer_status, struct layer_status_state, layer_status_update_cb, + layer_status_get_state) + +ZMK_SUBSCRIPTION(widget_layer_status, zmk_peripheral_status_relay_changed); + +#endif /* IS_ENABLED(CONFIG_ZMK_SPLIT_ROLE_CENTRAL) */ + int zmk_widget_layer_status_init(struct zmk_widget_layer_status *widget, lv_obj_t *parent) { widget->obj = lv_label_create(parent); diff --git a/zmk-config/boards/arm/anaphase/widgets/output_status.c b/zmk-config/boards/arm/anaphase/widgets/output_status.c index 16e9387..50ea658 100644 --- a/zmk-config/boards/arm/anaphase/widgets/output_status.c +++ b/zmk-config/boards/arm/anaphase/widgets/output_status.c @@ -13,6 +13,21 @@ LOG_MODULE_DECLARE(zmk, CONFIG_ZMK_LOG_LEVEL); #include "output_status.h" #include +/* + * Per-host nicknames for the 5 BLE profiles — shown on the central directly, + * and on the peripheral via the status relay. 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"}, + {"TABLET"}, + {"PHONE"}, + {"MISC"} +}; + #if IS_ENABLED(CONFIG_ZMK_SPLIT_ROLE_CENTRAL) /* @@ -29,20 +44,6 @@ LOG_MODULE_DECLARE(zmk, CONFIG_ZMK_LOG_LEVEL); #include #include -/* - * 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"}, - {"TABLET"}, - {"PHONE"}, - {"MISC"} -}; - static sys_slist_t widgets = SYS_SLIST_STATIC_INIT(&widgets); struct output_status_state { @@ -112,23 +113,36 @@ ZMK_SUBSCRIPTION(widget_output_status, zmk_ble_active_profile_changed); #include #include +#include "../peripheral_status_relay.h" static sys_slist_t widgets = SYS_SLIST_STATIC_INIT(&widgets); struct output_status_state { bool connected; + bool relay_valid; + uint8_t profile_index; }; static struct output_status_state get_state(const zmk_event_t *_eh) { return (struct output_status_state){ .connected = zmk_split_bt_peripheral_is_connected(), + .relay_valid = peripheral_status_relay_valid(), + .profile_index = peripheral_status_relay_profile_index(), }; } 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"); + + // Name row: the central's active profile nickname, via the status relay. + // Falls back to the static name until the first relay lands (or if the + // central runs a build without the relay). + if (state.relay_valid && state.profile_index < ARRAY_SIZE(name_array)) { + lv_label_set_text(name_label, name_array[state.profile_index]); + } else { + lv_label_set_text(name_label, "ANAPHASE"); + } } static void output_status_update_cb(struct output_status_state state) { @@ -141,6 +155,7 @@ static void output_status_update_cb(struct output_status_state 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); +ZMK_SUBSCRIPTION(widget_output_status, zmk_peripheral_status_relay_changed); #endif /* IS_ENABLED(CONFIG_ZMK_SPLIT_ROLE_CENTRAL) */ diff --git a/zmk-config/dts/bindings/behaviors/anaphase,behavior-status-relay.yaml b/zmk-config/dts/bindings/behaviors/anaphase,behavior-status-relay.yaml new file mode 100644 index 0000000..66c2735 --- /dev/null +++ b/zmk-config/dts/bindings/behaviors/anaphase,behavior-status-relay.yaml @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: MIT +# +# Carrier behavior for the anaphase peripheral status relay: the central +# invokes this on the peripheral over the stock split RUN_BEHAVIOR channel +# (the same mechanism as &bootloader locality), with param1 = layer id and +# param2 = active BLE profile index. Never bound in a keymap. + +description: anaphase peripheral status relay (central -> peripheral) + +compatible: "anaphase,behavior-status-relay" + +include: two_param.yaml From 31a4a288fa3abdc6242267b329322c1014570261 Mon Sep 17 00:00:00 2001 From: reversebias Date: Sun, 9 Aug 2026 22:09:52 +1000 Subject: [PATCH 2/5] Mark peripheral status relay done, verified on hardware Co-Authored-By: Claude Fable 5 --- zmk-next-steps.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/zmk-next-steps.md b/zmk-next-steps.md index dca9039..7804d4d 100644 --- a/zmk-next-steps.md +++ b/zmk-next-steps.md @@ -51,7 +51,11 @@ Bonus once running: layer *renaming* at runtime (`ZMK_KEYMAP_SETTINGS_STORAGE`), and it renders the physical layout — which is most of the work toward publishing the board upstream if that ever appeals. -## 3. Layer name on the right half — **not possible upstream, custom work** +## 3. Layer name on the right half — ✅ DONE 2026-08-09, verified on hardware +(Implemented per the DIY spec below, extended to also relay the active +profile nickname: see status_relay.c / stat_rly behavior / commit 72b18d7 on +peripheral-status-relay. Kill switch: CONFIG_ANAPHASE_STATUS_RELAY=n. +The v0.4 re-port risk below stands.) Answer to "was this possible now?": **no** — verified at v0.3.0 the split GATT service still has no layer characteristic (six characteristics, none carry layer or endpoint From 1192bd82ef31fbfa7c2a6f5438a4c005102d3b20 Mon Sep 17 00:00:00 2001 From: reversebias Date: Sun, 9 Aug 2026 22:17:17 +1000 Subject: [PATCH 3/5] Mirror central activity to wake the peripheral OLED on left keypresses The left screen wakes on any keypress because the central sees both halves' key events; the peripheral has no reverse traffic, so its OLED only woke on its own keys. Fix: the relay payload gains a central-activity flag (param2 bit 8). The central's activity state already represents the whole keyboard, so mirroring its ACTIVE/IDLE transitions onto the peripheral as synthetic zmk_activity_state_changed events gives the right OLED identical semantics to the left - wake ~20ms after the first keypress on either half, blank 60s after the last one. ZMK's stock blank-on-idle machinery does all the work; no per-keystroke traffic (transitions + the existing 15s refresh only), no custom blank manager. Deep sleep deliberately stays local to each half - the mirror never raises SLEEP. Co-Authored-By: Claude Fable 5 --- zmk-config/boards/arm/anaphase/status_relay.c | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/zmk-config/boards/arm/anaphase/status_relay.c b/zmk-config/boards/arm/anaphase/status_relay.c index a5b9050..ebce8af 100644 --- a/zmk-config/boards/arm/anaphase/status_relay.c +++ b/zmk-config/boards/arm/anaphase/status_relay.c @@ -10,7 +10,14 @@ * behavior on the peripheral — the same mechanism &bootloader locality uses. * * Wire format: behavior_dev = "stat_rly" (DT node name, <= 8 chars to fit - * the 9-byte split payload field), param1 = layer id, param2 = profile index. + * the 9-byte split payload field), param1 = layer id, + * param2 = profile index (bits 0-7) | central-activity flag (bit 8). + * + * The activity flag mirrors the central's activity state onto the + * peripheral (as a synthetic zmk_activity_state_changed event), so the + * right OLED wakes on LEFT-half keypresses and blanks only when the whole + * keyboard is idle — the same semantics the left screen gets for free + * (the central sees both halves' key events; the reverse has no traffic). * * Known limits (accepted, see zmk-next-steps.md §3): * - zmk_split_central_invoke_behavior is exported but undocumented; the @@ -34,9 +41,11 @@ LOG_MODULE_DECLARE(zmk, CONFIG_ZMK_LOG_LEVEL); #include #include +#include #include #include #include +#include #define RELAY_REFRESH_INTERVAL K_SECONDS(15) @@ -44,7 +53,8 @@ static void send_status(struct k_work *work) { struct zmk_behavior_binding binding = { .behavior_dev = "stat_rly", .param1 = zmk_keymap_layer_index_to_id(zmk_keymap_highest_layer_active()), - .param2 = zmk_ble_active_profile_index(), + .param2 = zmk_ble_active_profile_index() | + ((zmk_activity_get_state() == ZMK_ACTIVITY_ACTIVE ? 1 : 0) << 8), }; struct zmk_behavior_binding_event event = { .position = 0, @@ -70,6 +80,10 @@ static int status_relay_listener(const zmk_event_t *eh) { ZMK_LISTENER(status_relay, status_relay_listener); ZMK_SUBSCRIPTION(status_relay, zmk_layer_state_changed); ZMK_SUBSCRIPTION(status_relay, zmk_ble_active_profile_changed); +// Activity transitions: ACTIVE on the first keypress after idle (either +// half), IDLE 60s after the last one — this is what wakes/blanks the +// peripheral display remotely. +ZMK_SUBSCRIPTION(status_relay, zmk_activity_state_changed); static void relay_refresh(struct k_work *work) { send_status(work); @@ -100,12 +114,18 @@ SYS_INIT(status_relay_init, APPLICATION, CONFIG_APPLICATION_INIT_PRIORITY); #if DT_HAS_COMPAT_STATUS_OKAY(DT_DRV_COMPAT) +#include +#include + static struct { uint8_t layer_id; uint8_t profile_index; bool valid; } relay_state; +// -1 = unknown (before first relay); otherwise last mirrored 0/1. +static int8_t mirrored_central_active = -1; + bool peripheral_status_relay_valid(void) { return relay_state.valid; } uint8_t peripheral_status_relay_layer_id(void) { return relay_state.layer_id; } uint8_t peripheral_status_relay_profile_index(void) { return relay_state.profile_index; } @@ -113,7 +133,7 @@ uint8_t peripheral_status_relay_profile_index(void) { return relay_state.profile static int on_keymap_binding_pressed(struct zmk_behavior_binding *binding, struct zmk_behavior_binding_event event) { relay_state.layer_id = binding->param1; - relay_state.profile_index = binding->param2; + relay_state.profile_index = binding->param2 & 0xFF; relay_state.valid = true; raise_zmk_peripheral_status_relay_changed((struct zmk_peripheral_status_relay_changed){ @@ -121,6 +141,17 @@ static int on_keymap_binding_pressed(struct zmk_behavior_binding *binding, .profile_index = relay_state.profile_index, }); + // Mirror the central's activity state so ZMK's stock blank-on-idle + // machinery treats left-half typing as activity here. Only on change + // (the 15s refresh repeats the current state), and never for SLEEP — + // power management stays local. + int8_t central_active = (binding->param2 >> 8) & 1; + if (central_active != mirrored_central_active) { + mirrored_central_active = central_active; + raise_zmk_activity_state_changed((struct zmk_activity_state_changed){ + .state = central_active ? ZMK_ACTIVITY_ACTIVE : ZMK_ACTIVITY_IDLE}); + } + return ZMK_BEHAVIOR_OPAQUE; } From c48ba7ad4b7d8036d8cd82aef73527df1889ddc3 Mon Sep 17 00:00:00 2001 From: reversebias Date: Sun, 9 Aug 2026 22:32:31 +1000 Subject: [PATCH 4/5] Fix peripheral display blanking under sustained left-half typing Hardware test caught the hole: the wake worked, but the right half's own activity.c only watches local keys, so 60s after the last RIGHT keypress it raised IDLE and blanked - while the mirror's change-detector stayed silent (the central's state hadn't changed). Fix on the peripheral, two parts: - Local IDLE arriving while the mirrored central state is active AND fresh (relay heard <35s = two refresh periods + slack) is countered by re-asserting ACTIVE from a deferred work item (50ms; worst case a brief blink once per local-idle expiry). - The override is edge-triggered but staleness is a state: a watchdog re-checks at the freshness deadline, so a central that dies AFTER an override kept the screen on gets its claim dropped and the display blanked. A dead left half costs at most ~35s of extra screen-on time in every ordering. Co-Authored-By: Claude Fable 5 --- zmk-config/boards/arm/anaphase/status_relay.c | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/zmk-config/boards/arm/anaphase/status_relay.c b/zmk-config/boards/arm/anaphase/status_relay.c index ebce8af..6e18a4e 100644 --- a/zmk-config/boards/arm/anaphase/status_relay.c +++ b/zmk-config/boards/arm/anaphase/status_relay.c @@ -125,6 +125,17 @@ static struct { // -1 = unknown (before first relay); otherwise last mirrored 0/1. static int8_t mirrored_central_active = -1; +static int64_t last_relay_uptime; + +// Consider the mirrored flag stale if the central hasn't been heard from in +// two refresh periods + slack: a dead split link must never hold the +// display awake. (The central proves liveness every 15s via the relay's +// refresh timer, state change or not.) +#define RELAY_FRESH_MS (35 * MSEC_PER_SEC) + +static inline bool relay_is_fresh(void) { + return (k_uptime_get() - last_relay_uptime) < RELAY_FRESH_MS; +} bool peripheral_status_relay_valid(void) { return relay_state.valid; } uint8_t peripheral_status_relay_layer_id(void) { return relay_state.layer_id; } @@ -135,6 +146,7 @@ static int on_keymap_binding_pressed(struct zmk_behavior_binding *binding, relay_state.layer_id = binding->param1; relay_state.profile_index = binding->param2 & 0xFF; relay_state.valid = true; + last_relay_uptime = k_uptime_get(); raise_zmk_peripheral_status_relay_changed((struct zmk_peripheral_status_relay_changed){ .layer_id = relay_state.layer_id, @@ -155,6 +167,61 @@ static int on_keymap_binding_pressed(struct zmk_behavior_binding *binding, return ZMK_BEHAVIOR_OPAQUE; } +/* + * The local activity.c only watches this half's keys, so 60s after the last + * RIGHT-side keypress it raises IDLE and blanks the display — even while the + * central is active from left-side typing (the mirror's change-detector + * stays silent because the central's state didn't change). Counter it: when + * a local IDLE lands while the mirrored central state is active and fresh, + * re-assert ACTIVE from a deferred work item (deferred to avoid re-entrant + * event raising; the re-raised ACTIVE hits this listener too, but is a + * no-op). Worst case is a barely-visible blink as the display blanks and + * immediately unblanks once per local-idle expiry. + * + * The override is edge-triggered but the stale condition is a state, so a + * watchdog re-checks freshness at the deadline: if the central went quiet + * AFTER an override kept the screen on (no further local IDLE would ever + * arrive to re-evaluate), the watchdog drops the flag and blanks. While + * relays keep arriving it just pushes itself forward. + */ + +static void reassert_active_cb(struct k_work *work) { + raise_zmk_activity_state_changed( + (struct zmk_activity_state_changed){.state = ZMK_ACTIVITY_ACTIVE}); +} + +static K_WORK_DELAYABLE_DEFINE(reassert_active_work, reassert_active_cb); + +static void stale_watchdog_cb(struct k_work *work) { + if (mirrored_central_active != 1) { + return; // central went idle properly; normal blanking already applies + } + int64_t remaining = (last_relay_uptime + RELAY_FRESH_MS) - k_uptime_get(); + if (remaining > 0) { + k_work_reschedule(k_work_delayable_from_work(work), K_MSEC(remaining)); + return; + } + // Central went quiet while claiming active: drop the claim and blank. + mirrored_central_active = 0; + raise_zmk_activity_state_changed( + (struct zmk_activity_state_changed){.state = ZMK_ACTIVITY_IDLE}); +} + +static K_WORK_DELAYABLE_DEFINE(stale_watchdog_work, stale_watchdog_cb); + +static int relay_activity_listener(const zmk_event_t *eh) { + struct zmk_activity_state_changed *ev = as_zmk_activity_state_changed(eh); + if (ev != NULL && ev->state == ZMK_ACTIVITY_IDLE && mirrored_central_active == 1 && + relay_is_fresh()) { + k_work_reschedule(&reassert_active_work, K_MSEC(50)); + k_work_reschedule(&stale_watchdog_work, K_MSEC(RELAY_FRESH_MS)); + } + return ZMK_EV_EVENT_BUBBLE; +} + +ZMK_LISTENER(relay_activity, relay_activity_listener); +ZMK_SUBSCRIPTION(relay_activity, zmk_activity_state_changed); + static int on_keymap_binding_released(struct zmk_behavior_binding *binding, struct zmk_behavior_binding_event event) { return ZMK_BEHAVIOR_OPAQUE; From 7fadeee166fbd092d85c6f31abd27c9245a6d218 Mon Sep 17 00:00:00 2001 From: reversebias Date: Sun, 9 Aug 2026 22:42:25 +1000 Subject: [PATCH 5/5] Mark display wake mirroring verified on hardware All three test cases pass: sustained left-only typing keeps the right OLED lit (one blink at local-idle expiry), both screens blank together on whole-keyboard idle, and the watchdog blanks the right screen ~50s after the left half powers off mid-override. Co-Authored-By: Claude Fable 5 --- zmk-next-steps.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/zmk-next-steps.md b/zmk-next-steps.md index 7804d4d..d478881 100644 --- a/zmk-next-steps.md +++ b/zmk-next-steps.md @@ -52,10 +52,13 @@ and it renders the physical layout — which is most of the work toward publishi the board upstream if that ever appeals. ## 3. Layer name on the right half — ✅ DONE 2026-08-09, verified on hardware -(Implemented per the DIY spec below, extended to also relay the active -profile nickname: see status_relay.c / stat_rly behavior / commit 72b18d7 on -peripheral-status-relay. Kill switch: CONFIG_ANAPHASE_STATUS_RELAY=n. -The v0.4 re-port risk below stands.) +(Implemented per the DIY spec below, extended twice: also relays the active +profile nickname, and mirrors the central's activity state so the right OLED +wakes on left-half keypresses and blanks on whole-keyboard idle — with a +freshness watchdog so a dead left half can't pin the screen on (verified on +hardware: right blanked ~50s after left power-off). See status_relay.c / +stat_rly behavior on peripheral-status-relay. Kill switch: +CONFIG_ANAPHASE_STATUS_RELAY=n. The v0.4 re-port risk below stands.) Answer to "was this possible now?": **no** — verified at v0.3.0 the split GATT service still has no layer characteristic (six characteristics, none carry layer or endpoint