Files
ZephCore/zephcore/helpers/ui/ui_task.c
T
2026-03-03 13:46:48 +01:00

1055 lines
27 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* ZephCore - UI Task
* Copyright (c) 2025 ZephCore
* SPDX-License-Identifier: Apache-2.0
*
* Integration layer that wires buttons, buzzer, and display together.
* All event-driven via Zephyr input subsystem + k_work.
*
* Input flow (after longpress + multi-tap filter chain):
* KEY_1 → action_page_next() (1 tap, 400ms delayed)
* KEY_B → action_flood_advert() (2 taps, 400ms delayed)
* KEY_D → action_buzzer_toggle() (3 taps, 400ms delayed)
* KEY_C → action_gps_toggle() (4 taps, immediate)
* KEY_G → GPS switch on/off (hardware toggle, ThinkNode M1)
* KEY_POWER → action_deep_sleep() (long press ≥1s)
* KEY_RIGHT → action_page_next() (joystick, Wio Tracker)
* KEY_LEFT → action_page_prev() (joystick, Wio Tracker)
* KEY_ENTER → action_page_enter() (joystick center, Wio Tracker)
*
* Notification flow:
* LoRa RX → CompanionMesh → ui_notify(UI_EVENT_CONTACT_MSG)
* → buzzer_play(MELODY_MSG_CONTACT)
* → mc_display_on() + render
*/
#include "ui_task.h"
#include "ui_pages.h"
#ifdef CONFIG_ZEPHCORE_UI_BUZZER
#include "buzzer.h"
#endif
#ifdef CONFIG_ZEPHCORE_UI_DISPLAY
#include "display.h"
#endif
#include <zephyr/kernel.h>
#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM
#include "doom_game.h"
#endif
#include <zephyr/drivers/gpio.h>
#include <zephyr/input/input.h>
#include <zephyr/dt-bindings/input/input-event-codes.h>
#include <string.h>
#ifdef CONFIG_POWEROFF
#include <zephyr/sys/poweroff.h>
#endif
#include <zephyr/sys/reboot.h>
/* GPS control (extern "C" in ZephyrSensorManager.h) */
#include <ZephyrSensorManager.h>
/* Mesh action wrappers (deferred to mesh event loop thread) */
#include "ui_mesh_actions.h"
#include <zephyr/logging/log.h>
LOG_MODULE_REGISTER(ui_task, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL);
/* Tap-count feedback melodies.
* b=200, d=16 → each chirp ~75ms, rest ~75ms = clear separation.
*
* 2 taps (flood advert): chirp-chirp
* 3 taps (buzzer toggle): chirp×3 + high(ON) or low(OFF)
* 4 taps (GPS toggle): chirp×4 + high(ON) or low(OFF)
*
* ON tail: high E7 (~2637Hz) = "enabled"
* OFF tail: low G5 (~784Hz) = "disabled" */
#define MELODY_BEEP_2 "b2:d=16,o=7,b=200:c,p,c"
#define MELODY_BUZZER_ON "bon:d=16,o=7,b=200:c,p,c,p,c,p,p,8e"
#define MELODY_BUZZER_OFF "bof:d=16,o=7,b=200:c,p,c,p,c,p,p,8g5"
#define MELODY_GPS_ON "gon:d=16,o=7,b=200:c,p,c,p,c,p,c,p,p,8e"
#define MELODY_GPS_OFF "gof:d=16,o=7,b=200:c,p,c,p,c,p,c,p,p,8g5"
/* ========== LED Heartbeat ========== */
/* Match Arduino: 4s cycle, 20ms pulse (normal) or 200ms (unread messages).
* Uses led0 or led1 alias — whichever exists in the board's DTS.
* Disabled when OLED display is present (display makes LED redundant). */
/* Heartbeat LED — subtle pulse every 4s on led0 (or led1 fallback).
* Works alongside displays; boards that want to disable it can
* remove the led0 alias or override this with a Kconfig guard. */
#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios)
#define LED_NODE DT_ALIAS(led0)
static const struct gpio_dt_spec heartbeat_led =
GPIO_DT_SPEC_GET(LED_NODE, gpios);
#define HAS_HEARTBEAT_LED 1
#elif DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios)
#define LED_NODE DT_ALIAS(led1)
static const struct gpio_dt_spec heartbeat_led =
GPIO_DT_SPEC_GET(LED_NODE, gpios);
#define HAS_HEARTBEAT_LED 1
#else
#define HAS_HEARTBEAT_LED 0
#endif
#define LED_CYCLE_MS 4000 /* Total heartbeat period */
#define LED_ON_MS 20 /* Normal pulse width */
#define LED_ON_MSG_MS 200 /* Pulse width when unread messages */
/* ========== Deep Sleep / System OFF ========== */
/* On nRF52840, sys_poweroff() = System OFF (~1µA).
* Wake via reset button → full chip reset → boots fresh. */
/* When display is not available, maintain a local ui_state for setters
* (data can still be used for logging or future features). */
#ifndef CONFIG_ZEPHCORE_UI_DISPLAY
static struct ui_state local_ui_state;
#endif
/* ========== Constants ========== */
#define SPLASH_DURATION_MS 3000
#define RENDER_DEBOUNCE_MS 50
#define RENDER_DEBOUNCE_EPD_MS 200 /* e-paper: coalesce rapid state updates, then refresh */
/* ========== State ========== */
static bool ui_initialized;
static bool splash_active;
/* ========== Doom Easter Egg Activation ========== */
#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM
/*
* Activation sequence: double-click + single-click INPUT_KEY_ENTER on page 0.
* That's 3 presses total: press-press (double) then press (single).
* Deactivation: double-click INPUT_KEY_ENTER while Doom is running.
*
* State machine:
* IDLE → (1st press) → FIRST_PRESS
* FIRST_PRESS → (2nd press within 500ms) → DCLICK_DONE
* DCLICK_DONE → (3rd press within 500ms) → activate Doom
* Any state → (timeout) → IDLE
*/
enum doom_activate_state {
DOOM_ACT_IDLE,
DOOM_ACT_FIRST_PRESS,
DOOM_ACT_DCLICK_DONE,
};
static enum doom_activate_state doom_act_state;
static uint32_t doom_act_time;
#define DOOM_ACT_TIMEOUT_MS 500
#endif
static inline struct ui_state *get_state(void)
{
#ifdef CONFIG_ZEPHCORE_UI_DISPLAY
return ui_pages_get_state();
#else
return &local_ui_state;
#endif
}
/* Render work - debounced display update */
static struct k_work_delayable render_work;
/* Splash timeout work */
static struct k_work_delayable splash_work;
/* Deferred zero-hop advert — waits for possible double-press upgrade to flood */
static struct k_work_delayable advert_defer_work;
/* LED heartbeat only on boards with LED but no OLED (display makes it redundant) */
#if HAS_HEARTBEAT_LED
/* LED heartbeat: two one-shot works form a self-rescheduling cycle.
* led_on_work turns LED on and schedules led_off_work after pulse width.
* led_off_work turns LED off and schedules led_on_work after remainder. */
static struct k_work_delayable led_on_work;
static struct k_work_delayable led_off_work;
#endif
/* ========== Work Handlers ========== */
#if HAS_HEARTBEAT_LED
static void led_off_work_handler(struct k_work *work)
{
ARG_UNUSED(work);
gpio_pin_set_dt(&heartbeat_led, 0);
/* Schedule next ON after remainder of cycle */
struct ui_state *s = get_state();
uint16_t on_ms = (s->msg_count > 0) ? LED_ON_MSG_MS : LED_ON_MS;
k_work_reschedule(&led_on_work, K_MSEC(LED_CYCLE_MS - on_ms));
}
static void led_on_work_handler(struct k_work *work)
{
ARG_UNUSED(work);
gpio_pin_set_dt(&heartbeat_led, 1);
/* Schedule OFF after pulse width */
struct ui_state *s = get_state();
uint16_t on_ms = (s->msg_count > 0) ? LED_ON_MSG_MS : LED_ON_MS;
k_work_reschedule(&led_off_work, K_MSEC(on_ms));
}
#endif
static void render_work_handler(struct k_work *work)
{
ARG_UNUSED(work);
if (!ui_initialized) {
return;
}
#ifdef CONFIG_ZEPHCORE_UI_DISPLAY
if (mc_display_is_on() && !splash_active) {
ui_pages_render();
}
#endif
}
static void splash_work_handler(struct k_work *work)
{
ARG_UNUSED(work);
splash_active = false;
#ifdef CONFIG_ZEPHCORE_UI_DISPLAY
/* Transition from splash to home page (first active page for this role) */
#ifdef ZEPHCORE_REPEATER
ui_pages_set(UI_PAGE_STATUS);
#else
ui_pages_set(UI_PAGE_MESSAGES);
#endif
k_work_reschedule(&render_work, K_NO_WAIT);
#endif
}
static void schedule_render(void);
static void advert_defer_handler(struct k_work *work)
{
ARG_UNUSED(work);
/* Timer expired without second press — send zero-hop */
LOG_INF("zero-hop advert requested (deferred)");
#ifdef CONFIG_ZEPHCORE_UI_BUZZER
buzzer_play(MELODY_BEEP_2);
#endif
mesh_send_zerohop_advert();
#ifdef CONFIG_ZEPHCORE_UI_DISPLAY
ui_pages_advert_sent(false);
#endif
schedule_render();
}
static void schedule_render(void)
{
#ifdef CONFIG_ZEPHCORE_UI_DISPLAY
uint32_t debounce = mc_display_is_epd() ? RENDER_DEBOUNCE_EPD_MS
: RENDER_DEBOUNCE_MS;
k_work_reschedule(&render_work, K_MSEC(debounce));
#endif
}
/* ========== Button Action Functions ========== */
/* Each action checks capabilities internally — no #ifdef in the switch. */
static void action_page_next(void)
{
#ifdef CONFIG_ZEPHCORE_UI_DISPLAY
ui_pages_next();
#endif
schedule_render();
}
static void action_page_prev(void)
{
#ifdef CONFIG_ZEPHCORE_UI_DISPLAY
ui_pages_prev();
#endif
schedule_render();
}
/* Forward declarations for page-enter dispatch */
static void action_flood_advert(void);
static void action_gps_toggle(void);
static void action_buzzer_toggle(void);
#ifdef CONFIG_ZEPHCORE_UI_DISPLAY
static void action_ble_toggle(void);
static void action_enter_dfu(void);
#endif
static void action_deep_sleep(void);
static void action_page_enter(void)
{
#ifdef CONFIG_ZEPHCORE_UI_DISPLAY
enum ui_page page = ui_pages_current();
LOG_INF("ENTER on page %d", page);
switch (page) {
case UI_PAGE_BLUETOOTH:
/* Toggle BLE on/off */
action_ble_toggle();
break;
case UI_PAGE_ADVERT:
/* Single press: defer 500ms then send zero-hop.
* Double press within 500ms: cancel deferred, send flood. */
if (k_work_delayable_is_pending(&advert_defer_work)) {
/* Second press — cancel deferred zero-hop, send flood */
k_work_cancel_delayable(&advert_defer_work);
action_flood_advert();
} else {
/* First press — start deferred zero-hop */
k_work_reschedule(&advert_defer_work, K_MSEC(500));
}
break;
case UI_PAGE_GPS:
/* Toggle GPS (same as quad-tap) */
action_gps_toggle();
break;
case UI_PAGE_BUZZER:
/* Toggle buzzer mute (same as triple-tap) */
action_buzzer_toggle();
break;
case UI_PAGE_OFFGRID: {
/* Double-press confirmation (500ms window) */
struct ui_state *st_og = get_state();
uint32_t now_og = k_uptime_get_32();
if (st_og->offgrid_confirm_time != 0 &&
(now_og - st_og->offgrid_confirm_time) <= 500) {
/* Confirmed — toggle offgrid mode */
bool new_state = !st_og->offgrid_enabled;
st_og->offgrid_enabled = new_state;
st_og->offgrid_confirm_time = 0;
mesh_set_offgrid_mode(new_state);
LOG_INF("offgrid mode %s (button)", new_state ? "on" : "off");
} else {
/* First press — enter confirmation state */
st_og->offgrid_confirm_time = now_og;
}
schedule_render();
break;
}
case UI_PAGE_DFU: {
/* Double-press confirmation (500ms window) */
struct ui_state *st_dfu = get_state();
uint32_t now_dfu = k_uptime_get_32();
if (st_dfu->dfu_confirm_time != 0 &&
(now_dfu - st_dfu->dfu_confirm_time) <= 500) {
/* Confirmed — reboot into BLE DFU */
action_enter_dfu();
} else {
/* First press — enter confirmation state */
st_dfu->dfu_confirm_time = now_dfu;
schedule_render();
}
break;
}
case UI_PAGE_SHUTDOWN: {
/* Double-press confirmation (500ms window) */
struct ui_state *st = get_state();
uint32_t now = k_uptime_get_32();
if (st->shutdown_confirm_time != 0 &&
(now - st->shutdown_confirm_time) <= 500) {
/* Confirmed — shut down */
action_deep_sleep();
} else {
/* First press — enter confirmation state */
st->shutdown_confirm_time = now;
schedule_render();
}
break;
}
case UI_PAGE_MESSAGES:
#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM
{
/* Doom activation: 3 presses — double-click + single-click.
* Press 1 → FIRST_PRESS
* Press 2 (within 500ms) → DCLICK_DONE
* Press 3 (within 500ms) → activate Doom */
uint32_t now_doom = k_uptime_get_32();
switch (doom_act_state) {
case DOOM_ACT_IDLE:
doom_act_time = now_doom;
doom_act_state = DOOM_ACT_FIRST_PRESS;
break;
case DOOM_ACT_FIRST_PRESS:
if ((now_doom - doom_act_time) <= DOOM_ACT_TIMEOUT_MS) {
/* Double-click detected, wait for single */
doom_act_time = now_doom;
doom_act_state = DOOM_ACT_DCLICK_DONE;
} else {
/* Timeout — restart as new first press */
doom_act_time = now_doom;
doom_act_state = DOOM_ACT_FIRST_PRESS;
}
break;
case DOOM_ACT_DCLICK_DONE:
if ((now_doom - doom_act_time) <= DOOM_ACT_TIMEOUT_MS) {
/* Third press — activate Doom! */
doom_act_state = DOOM_ACT_IDLE;
LOG_INF("Doom easter egg activated!");
doom_game_start();
} else {
/* Timeout — restart */
doom_act_time = now_doom;
doom_act_state = DOOM_ACT_FIRST_PRESS;
}
break;
}
break;
}
#endif
/* fall through if doom not enabled */
default:
/* Other pages: no action on ENTER */
break;
}
#endif
}
static void action_flood_advert(void)
{
LOG_INF("flood advert requested");
#ifdef CONFIG_ZEPHCORE_UI_BUZZER
buzzer_play(MELODY_BEEP_2);
#endif
mesh_send_flood_advert();
#ifdef CONFIG_ZEPHCORE_UI_DISPLAY
ui_pages_advert_sent(true);
#endif
schedule_render();
}
static void action_buzzer_toggle(void)
{
#ifdef CONFIG_ZEPHCORE_UI_BUZZER
bool was_quiet = buzzer_is_quiet();
if (was_quiet) {
/* Unmuting: enable first, then play ascending confirmation */
buzzer_set_quiet(false);
buzzer_play(MELODY_BUZZER_ON);
} else {
/* Muting: play descending confirmation while still enabled.
* Use deferred mute so the "off" melody plays out fully
* before the quiet flag suppresses future sounds. */
buzzer_play(MELODY_BUZZER_OFF);
buzzer_set_quiet_deferred(true);
}
/* Persist mute state across reboots */
mesh_set_buzzer_quiet(!was_quiet);
get_state()->buzzer_quiet = !was_quiet;
LOG_INF("buzzer %s", buzzer_is_quiet() ? "muted" : "unmuted");
#endif
schedule_render();
}
static void action_gps_toggle(void)
{
if (!gps_is_available()) {
LOG_WRN("GPS toggle ignored — no GPS hardware");
return;
}
bool now_enabled = !gps_is_enabled();
LOG_INF("GPS toggle → %s", now_enabled ? "on" : "off");
#ifdef CONFIG_ZEPHCORE_UI_BUZZER
buzzer_play(now_enabled ? MELODY_GPS_ON : MELODY_GPS_OFF);
#endif
/* Use persistent wrapper — same path as BLE CMD_SET_CUSTOM_VAR "gps" */
mesh_gps_set_enabled(now_enabled);
schedule_render();
}
#ifdef CONFIG_ZEPHCORE_UI_DISPLAY
static void action_ble_toggle(void)
{
struct ui_state *s = get_state();
bool now_enabled = !s->ble_enabled;
LOG_INF("BLE toggle → %s", now_enabled ? "on" : "off");
s->ble_enabled = now_enabled;
mesh_ble_set_enabled(now_enabled);
schedule_render();
}
static void action_enter_dfu(void)
{
LOG_INF("entering BLE DFU bootloader");
#ifdef CONFIG_ZEPHCORE_UI_BUZZER
buzzer_play(MELODY_SHUTDOWN);
while (buzzer_is_playing()) {
k_sleep(K_MSEC(50));
}
buzzer_stop();
#endif
mc_display_clear();
mc_display_text(16, 28, "BLE DFU...", false);
mc_display_finalize();
k_sleep(K_MSEC(500));
mc_display_off();
/* Delegate to board adapter — handles GPREGRET + reset for any platform */
mesh_reboot_to_ota_dfu();
CODE_UNREACHABLE;
}
#endif /* CONFIG_ZEPHCORE_UI_DISPLAY */
static void action_deep_sleep(void)
{
#ifdef CONFIG_POWEROFF
LOG_INF("deep sleep: shutting down...");
/* 1. Stop LED heartbeat */
#if HAS_HEARTBEAT_LED
k_work_cancel_delayable(&led_on_work);
k_work_cancel_delayable(&led_off_work);
gpio_pin_set_dt(&heartbeat_led, 0);
#endif
/* 2. Play shutdown melody (blocking wait) */
#ifdef CONFIG_ZEPHCORE_UI_BUZZER
buzzer_play(MELODY_SHUTDOWN);
while (buzzer_is_playing()) {
k_sleep(K_MSEC(50));
}
buzzer_stop();
#endif
/* 3. Turn display off */
#ifdef CONFIG_ZEPHCORE_UI_DISPLAY
mc_display_off();
#endif
/* 4. Drive power-hungry enable pins LOW.
*
* nRF52840 GPIO output latches persist across System OFF and reset.
* If GPS_EN is HIGH, the GPS module stays powered during "sleep."
* Drive known power-enable GPIOs LOW via Zephyr's safe GPIO API.
*
* DO NOT touch BLE (bt_conn_disconnect / bt_le_adv_stop) — that
* corrupts BLE controller state and prevents clean reboot.
* DO NOT blank all GPIOs — that bricked the device previously. */
gps_power_off_for_shutdown();
mesh_disable_power_regulators();
/* 5. Enter System OFF.
* Wake via reset button → full chip reset → clean boot. */
LOG_INF("deep sleep: entering System OFF");
sys_poweroff();
CODE_UNREACHABLE;
#else
LOG_WRN("deep sleep: CONFIG_POWEROFF not enabled");
#endif
}
/* ========== Input Event Handler ========== */
static void ui_input_cb(struct input_event *evt, void *user_data)
{
ARG_UNUSED(user_data);
if (evt->type != INPUT_EV_KEY) {
return;
}
#ifdef CONFIG_ZEPHCORE_EASTER_EGG_DOOM
/* When Doom is running, intercept ALL input (presses AND releases) */
if (doom_game_is_running()) {
/* Double-click ENTER to exit: detect two presses within 500ms */
if (evt->code == INPUT_KEY_ENTER && evt->value) {
static uint32_t doom_last_enter;
uint32_t now = k_uptime_get_32();
if (doom_last_enter != 0 &&
(now - doom_last_enter) <= 500) {
/* Double-click — exit Doom */
doom_last_enter = 0;
doom_game_stop();
schedule_render();
return;
}
doom_last_enter = now;
}
/* Forward all key events (press + release) to Doom */
doom_game_input(evt->code, evt->value);
return;
}
#endif
/* GPS hardware switch (toggle switch, not momentary button).
* Needs both press (ON) and release (OFF) events,
* so handle before the release-event filter below. */
if (evt->code == INPUT_KEY_G) {
bool gps_on = (evt->value != 0);
LOG_INF("GPS switch → %s", gps_on ? "on" : "off");
if (gps_is_available()) {
mesh_gps_set_enabled(gps_on);
#ifdef CONFIG_ZEPHCORE_UI_BUZZER
buzzer_play(gps_on ? MELODY_GPS_ON : MELODY_GPS_OFF);
#endif
schedule_render();
}
return;
}
/* Only handle key press events (value=1), not releases (value=0) */
if (!evt->value) {
return;
}
LOG_INF("UI input: code=%u value=%u", evt->code, evt->value);
#ifdef CONFIG_ZEPHCORE_UI_DISPLAY
/* If display is off, wake it and consume the event */
if (!mc_display_is_on()) {
mc_display_on();
schedule_render();
return;
}
/* Reset auto-off timer on any button press */
mc_display_reset_auto_off();
#endif
/* Dismiss splash screen on any button press */
if (splash_active) {
k_work_cancel_delayable(&splash_work);
splash_active = false;
#ifdef CONFIG_ZEPHCORE_UI_DISPLAY
#ifdef ZEPHCORE_REPEATER
ui_pages_set(UI_PAGE_STATUS);
#else
ui_pages_set(UI_PAGE_MESSAGES);
#endif
#endif
schedule_render();
return;
}
/* Map input key codes to UI actions.
*
* KEY_1: 1 tap (from multi-tap, 400ms delayed)
* KEY_B: 2 taps (from multi-tap, 400ms delayed)
* KEY_D: 3 taps (from multi-tap, 400ms delayed)
* KEY_C: 4 taps (from multi-tap, immediate — max count)
* KEY_POWER: long press (from longpress filter, ≥1s)
* KEY_RIGHT/LEFT/ENTER/UP/DOWN: joystick (Wio Tracker)
*
* NOTE: KEY_A (raw short-press from longpress filter) is NOT handled
* here. It feeds into the multi-tap filter which emits the tap codes.
* Since INPUT_CALLBACK_DEFINE(NULL) sees events from all devices,
* the raw KEY_A events fall through to default: break.
*/
switch (evt->code) {
/* ===== Multi-tap outputs ===== */
case INPUT_KEY_1:
/* Single tap (400ms delayed): page next */
action_page_next();
break;
case INPUT_KEY_B:
/* Double tap (400ms delayed): flood advert */
action_flood_advert();
break;
case INPUT_KEY_D:
/* Triple tap (400ms delayed): toggle buzzer mute */
action_buzzer_toggle();
break;
case INPUT_KEY_C:
/* Quadruple tap (immediate): toggle GPS */
action_gps_toggle();
break;
/* ===== Longpress output ===== */
case INPUT_KEY_POWER:
case INPUT_KEY_F:
/* Long press (≥1s): deep sleep */
action_deep_sleep();
break;
/* ===== Joystick (Wio Tracker) ===== */
case INPUT_KEY_RIGHT:
action_page_next();
break;
case INPUT_KEY_LEFT:
action_page_prev();
break;
case INPUT_KEY_ENTER:
action_page_enter();
break;
case INPUT_KEY_UP:
case INPUT_KEY_DOWN:
/* Joystick up/down - scroll within page (future) */
break;
default:
break;
}
}
/* Register input callback for ALL devices (no specific device filter) */
INPUT_CALLBACK_DEFINE(NULL, ui_input_cb, NULL);
/* ========== Public API ========== */
int ui_init(void)
{
int ret;
k_work_init_delayable(&render_work, render_work_handler);
k_work_init_delayable(&splash_work, splash_work_handler);
k_work_init_delayable(&advert_defer_work, advert_defer_handler);
/* Initialize buzzer (optional - may not be present) */
#ifdef CONFIG_ZEPHCORE_UI_BUZZER
ret = buzzer_init();
if (ret == 0) {
LOG_INF("buzzer ready");
} else if (ret == -ENODEV) {
LOG_INF("no buzzer hardware");
} else {
LOG_WRN("buzzer init failed: %d", ret);
}
#endif
/* Initialize display (optional - may not be present) */
#ifdef CONFIG_ZEPHCORE_UI_DISPLAY
ret = mc_display_init();
if (ret == 0) {
LOG_INF("display ready");
/* Show splash screen */
splash_active = true;
ui_pages_render_splash();
/* Start auto-off timer so display sleeps after timeout */
mc_display_reset_auto_off();
/* Schedule transition to home page */
k_work_reschedule(&splash_work, K_MSEC(SPLASH_DURATION_MS));
} else if (ret == -ENODEV) {
LOG_INF("no display hardware");
} else {
LOG_WRN("display init failed: %d", ret);
}
#endif
/* Initialize LED heartbeat — only on boards WITHOUT a display.
* If there's an OLED, the heartbeat LED is redundant and wastes power. */
#if HAS_HEARTBEAT_LED
if (gpio_is_ready_dt(&heartbeat_led)) {
gpio_pin_configure_dt(&heartbeat_led, GPIO_OUTPUT_INACTIVE);
k_work_init_delayable(&led_on_work, led_on_work_handler);
k_work_init_delayable(&led_off_work, led_off_work_handler);
/* Start heartbeat cycle */
k_work_reschedule(&led_on_work, K_NO_WAIT);
LOG_INF("LED heartbeat started");
}
#endif
/* NOTE: startup chime is NOT played here. It's played from main()
* after loadPrefs() so we can respect the persisted buzzer_quiet setting.
* See ui_play_startup_chime(). */
ui_initialized = true;
LOG_INF("UI initialized");
/* Suppress unused variable warning when both display and buzzer are disabled */
(void)ret;
return 0;
}
void ui_play_startup_chime(void)
{
#ifdef CONFIG_ZEPHCORE_UI_BUZZER
if (!buzzer_is_quiet()) {
buzzer_play(MELODY_STARTUP);
}
#endif
}
void ui_notify(enum ui_event event)
{
if (!ui_initialized) {
return;
}
bool is_msg_event = false;
switch (event) {
case UI_EVENT_CONTACT_MSG:
is_msg_event = true;
#ifdef CONFIG_ZEPHCORE_UI_BUZZER
/* Only buzz if no phone is connected to receive the message */
if (!get_state()->ble_connected) {
buzzer_play(MELODY_MSG_CONTACT);
}
#endif
break;
case UI_EVENT_CHANNEL_MSG:
is_msg_event = true;
#ifdef CONFIG_ZEPHCORE_UI_BUZZER
if (!get_state()->ble_connected) {
buzzer_play(MELODY_MSG_CHANNEL);
}
#endif
break;
case UI_EVENT_ROOM_MSG:
is_msg_event = true;
#ifdef CONFIG_ZEPHCORE_UI_BUZZER
if (!get_state()->ble_connected) {
buzzer_play(MELODY_MSG_CHANNEL);
}
#endif
break;
case UI_EVENT_ACK:
#ifdef CONFIG_ZEPHCORE_UI_BUZZER
buzzer_play(MELODY_ACK);
#endif
break;
case UI_EVENT_BLE_CONNECTED:
ui_set_ble_status(true, NULL);
break;
case UI_EVENT_BLE_DISCONNECTED:
ui_set_ble_status(false, NULL);
break;
default:
break;
}
/* Wake display on notifications.
* Repeater: never wake display on events — only user button wakes it.
* Companion: wake unless phone is connected (phone handles its own). */
#ifdef CONFIG_ZEPHCORE_UI_DISPLAY
#ifndef ZEPHCORE_REPEATER
if (!(is_msg_event && get_state()->ble_connected)) {
mc_display_on();
schedule_render();
}
#endif
#endif
}
void ui_set_msg_count(uint16_t count)
{
struct ui_state *s = get_state();
s->msg_count = count;
if (ui_initialized) {
schedule_render();
}
}
void ui_set_ble_status(bool connected, const char *name)
{
struct ui_state *s = get_state();
s->ble_connected = connected;
if (name) {
strncpy(s->device_name, name, sizeof(s->device_name) - 1);
s->device_name[sizeof(s->device_name) - 1] = '\0';
}
if (ui_initialized) {
schedule_render();
}
}
void ui_set_radio_params(uint32_t freq_hz, uint8_t sf, uint16_t bw_khz_x10,
uint8_t cr, int8_t tx_power, int16_t noise_floor)
{
struct ui_state *s = get_state();
s->lora_freq_hz = freq_hz;
s->lora_sf = sf;
s->lora_bw_khz_x10 = bw_khz_x10;
s->lora_cr = cr;
s->lora_tx_power = tx_power;
s->lora_noise_floor = noise_floor;
}
void ui_set_gps_data(bool has_fix, uint8_t sats,
int32_t lat_mdeg, int32_t lon_mdeg, int32_t alt_mm)
{
struct ui_state *s = get_state();
s->gps_has_fix = has_fix;
s->gps_satellites = sats;
s->gps_lat_mdeg = lat_mdeg;
s->gps_lon_mdeg = lon_mdeg;
s->gps_alt_mm = alt_mm;
}
void ui_set_battery(uint16_t mv, uint8_t pct)
{
struct ui_state *s = get_state();
s->battery_mv = mv;
s->battery_pct = pct;
}
void ui_set_clock(uint32_t epoch)
{
struct ui_state *s = get_state();
s->rtc_epoch = epoch;
}
void ui_add_recent(const char *name, int16_t rssi, uint32_t age_s)
{
struct ui_state *s = get_state();
/* Shift entries down if full */
if (s->recent_count >= 4) {
memmove(&s->recent[1], &s->recent[0], sizeof(s->recent[0]) * 3);
} else {
if (s->recent_count > 0) {
memmove(&s->recent[1], &s->recent[0],
sizeof(s->recent[0]) * s->recent_count);
}
s->recent_count++;
}
/* Add new entry at top — sanitize UTF-8 to Latin-1 for display font */
#ifdef CONFIG_ZEPHCORE_UI_DISPLAY
utf8_to_latin1(s->recent[0].name, name, sizeof(s->recent[0].name));
#else
strncpy(s->recent[0].name, name, sizeof(s->recent[0].name) - 1);
s->recent[0].name[sizeof(s->recent[0].name) - 1] = '\0';
#endif
s->recent[0].rssi = rssi;
s->recent[0].age_s = age_s;
}
void ui_clear_recent(void)
{
struct ui_state *s = get_state();
s->recent_count = 0;
memset(s->recent, 0, sizeof(s->recent));
}
void ui_set_node_name(const char *name)
{
struct ui_state *s = get_state();
if (name) {
strncpy(s->node_name, name, sizeof(s->node_name) - 1);
s->node_name[sizeof(s->node_name) - 1] = '\0';
}
#ifdef CONFIG_ZEPHCORE_UI_DISPLAY
ui_pages_set_node_name(name);
#endif
}
void ui_set_sensor_data(int16_t temp_c10, uint32_t pressure_pa,
uint16_t humidity_rh10, uint16_t light_lux)
{
struct ui_state *s = get_state();
s->temperature_c10 = temp_c10;
s->pressure_pa = pressure_pa;
s->humidity_rh10 = humidity_rh10;
s->light_lux = light_lux;
}
void ui_set_gps_available(bool available)
{
struct ui_state *s = get_state();
s->gps_available = available;
}
void ui_set_gps_enabled(bool enabled)
{
struct ui_state *s = get_state();
s->gps_enabled = enabled;
}
void ui_set_gps_state(uint8_t state, uint32_t last_fix_age_s, uint32_t next_search_s)
{
struct ui_state *s = get_state();
s->gps_state = state;
s->gps_last_fix_age_s = last_fix_age_s;
s->gps_next_search_s = next_search_s;
}
void ui_set_ble_enabled(bool enabled)
{
struct ui_state *s = get_state();
s->ble_enabled = enabled;
}
void ui_set_buzzer_quiet(bool quiet)
{
struct ui_state *s = get_state();
s->buzzer_quiet = quiet;
}
void ui_set_offgrid_mode(bool enabled)
{
struct ui_state *s = get_state();
s->offgrid_enabled = enabled;
}
void ui_refresh_display(void)
{
if (!ui_initialized) {
return;
}
#ifdef CONFIG_ZEPHCORE_UI_DISPLAY
/* EPD displays: skip periodic housekeeping renders.
* Each full e-paper refresh takes ~2s and causes visible flashing.
* All meaningful events (messages, BLE, GPS fix, button presses)
* already trigger renders via their own ui_set_*() → schedule_render().
* Housekeeping just updates slow-changing data (clock, contact ages)
* which will appear on the next event-driven render. */
if (mc_display_is_epd()) {
return;
}
schedule_render();
#endif
}