Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions zmk-config/boards/arm/anaphase/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions zmk-config/boards/arm/anaphase/Kconfig.defconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 11 additions & 0 deletions zmk-config/boards/arm/anaphase/anaphase.dtsi
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 3 additions & 2 deletions zmk-config/boards/arm/anaphase/anaphase_right_defconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 22 additions & 0 deletions zmk-config/boards/arm/anaphase/peripheral_status_relay.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2026
*
* SPDX-License-Identifier: MIT
*/

#pragma once

#include <zmk/event_manager.h>

// 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);
242 changes: 242 additions & 0 deletions zmk-config/boards/arm/anaphase/status_relay.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
/*
* 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 (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
* 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 <zephyr/kernel.h>
#include <zephyr/logging/log.h>
LOG_MODULE_DECLARE(zmk, CONFIG_ZMK_LOG_LEVEL);

#include <zmk/behavior.h>
#include <zmk/event_manager.h>

#if IS_ENABLED(CONFIG_ZMK_SPLIT_ROLE_CENTRAL)

/* ------------------------------------------------------------------ */
/* Central: watch layer/profile state, push snapshots to peripherals. */
/* ------------------------------------------------------------------ */

#include <zmk/keymap.h>
#include <zmk/ble.h>
#include <zmk/activity.h>
#include <zmk/split/central.h>
#include <zmk/events/layer_state_changed.h>
#include <zmk/events/ble_active_profile_changed.h>
#include <zmk/events/activity_state_changed.h>

#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() |
((zmk_activity_get_state() == ZMK_ACTIVITY_ACTIVE ? 1 : 0) << 8),
};
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);
// 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);
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 <zephyr/device.h>
#include <drivers/behavior.h>
#include "peripheral_status_relay.h"

#if DT_HAS_COMPAT_STATUS_OKAY(DT_DRV_COMPAT)

#include <zmk/activity.h>
#include <zmk/events/activity_state_changed.h>

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;
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; }
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 & 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,
.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;
}

/*
* 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;
}

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) */
60 changes: 58 additions & 2 deletions zmk-config/boards/arm/anaphase/widgets/layer_status.c
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,17 @@ LOG_MODULE_DECLARE(zmk, CONFIG_ZMK_LOG_LEVEL);

#include <zmk/display.h>
#include "layer_status.h"
#include <zmk/events/layer_state_changed.h>
#include <zmk/event_manager.h>
#include <zmk/keymap.h>

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 <zmk/events/layer_state_changed.h>
#include <zmk/keymap.h>

struct layer_status_state {
zmk_keymap_layer_index_t index;
const char *label;
Expand Down Expand Up @@ -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);

Expand Down
Loading
Loading