/* * ZephCore - UI Common * Copyright (c) 2025 ZephCore * SPDX-License-Identifier: MIT * * Shared ui_task.h implementations that are identical across all UI variants. * Compiled for every build that includes any UI (button, joystick, or future). */ #include "ui_task.h" #ifdef CONFIG_ZEPHCORE_UI_BUZZER #include "buzzer.h" #endif #ifdef CONFIG_ZEPHCORE_UI_DISPLAY #include "display.h" #endif #include #include "led_gate.h" /* shared with the LoRa TX LED */ #include #include #include #include #include #include LOG_MODULE_REGISTER(ui_led, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL); /* ========== Input axis flip ========== * Shared by both UI variants. Written from the mesh/CLI thread, read from the * input callback; a plain bool is enough. */ static bool input_flipped; void zephcore_input_set_flipped(bool flipped) { input_flipped = flipped; } bool zephcore_input_is_flipped(void) { return input_flipped; } uint16_t zephcore_input_map_code(uint16_t code) { if (!input_flipped) { return code; } switch (code) { case INPUT_KEY_UP: return INPUT_KEY_DOWN; case INPUT_KEY_DOWN: return INPUT_KEY_UP; case INPUT_KEY_LEFT: return INPUT_KEY_RIGHT; case INPUT_KEY_RIGHT: return INPUT_KEY_LEFT; default: return code; } } /* ========== Startup Chime ========== */ void ui_play_startup_chime(void) { #ifdef CONFIG_ZEPHCORE_UI_BUZZER if (!buzzer_is_quiet()) { buzzer_play(MELODY_STARTUP); } #endif } /* ========== LED Heartbeat ========== */ /* * Heartbeat on led0 (or led1). The pulse widens while there are unread * messages; with both LEDs present, companions light led1 for unread. */ /* PWM path (brightness-capable, shared with the LoRa TX LED via the same * DT_ALIAS(heartbeat_pwm_led)/DT_ALIAS(lora_tx_pwm_led) pair when a board's * heartbeat and TX indicator are the same physical LED, e.g. T096) takes * priority over the plain digital led0/led1 fallback. Brightness comes from * helpers/led_gate.h — one dimmer for every event on this LED, not a * heartbeat-only one. */ #if DT_NODE_EXISTS(DT_ALIAS(heartbeat_pwm_led)) static const struct pwm_dt_spec s_heartbeat_led_pwm = PWM_DT_SPEC_GET(DT_ALIAS(heartbeat_pwm_led)); #define HAS_HEARTBEAT_LED_PWM 1 #define HAS_HEARTBEAT_LED 0 #elif DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) static const struct gpio_dt_spec s_heartbeat_led = GPIO_DT_SPEC_GET(DT_ALIAS(led0), gpios); #define HAS_HEARTBEAT_LED_PWM 0 #define HAS_HEARTBEAT_LED 1 #elif DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios) static const struct gpio_dt_spec s_heartbeat_led = GPIO_DT_SPEC_GET(DT_ALIAS(led1), gpios); #define HAS_HEARTBEAT_LED_PWM 0 #define HAS_HEARTBEAT_LED 1 #else #define HAS_HEARTBEAT_LED_PWM 0 #define HAS_HEARTBEAT_LED 0 #endif /* "Some heartbeat LED exists, PWM or plain GPIO" — used where the on/off * write differs but everything else (work queue plumbing, readiness check) * is identical either way. */ #define HAS_ANY_HEARTBEAT_LED (HAS_HEARTBEAT_LED_PWM || HAS_HEARTBEAT_LED) #if HAS_HEARTBEAT_LED_PWM static inline bool heartbeat_led_is_ready(void) { return pwm_is_ready_dt(&s_heartbeat_led_pwm); } static inline void heartbeat_led_write(bool on) { uint32_t pulse = on ? (uint32_t)((uint64_t)s_heartbeat_led_pwm.period * zephcore_led_brightness_pct() / 100) : 0; pwm_set_pulse_dt(&s_heartbeat_led_pwm, pulse); } #elif HAS_HEARTBEAT_LED static inline bool heartbeat_led_is_ready(void) { return gpio_is_ready_dt(&s_heartbeat_led); } static inline void heartbeat_led_write(bool on) { gpio_pin_set_dt(&s_heartbeat_led, on ? 1 : 0); } #endif /* Second LED for unread-message indication. Repeaters use led1 for LoRa TX * (via lora-tx-led alias) — no offline queue, so this is companion-only. */ #if HAS_ANY_HEARTBEAT_LED && DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) && \ DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios) && !defined(ZEPHCORE_REPEATER) static const struct gpio_dt_spec s_msg_led = GPIO_DT_SPEC_GET(DT_ALIAS(led1), gpios); #define HAS_MSG_LED 1 #else #define HAS_MSG_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 */ #if HAS_ANY_HEARTBEAT_LED static struct k_work_delayable s_led_on_work; static struct k_work_delayable s_led_off_work; /* * Weak: returns current unread message count for pulse-width adaptation. * Overridden by ui_task.c (button UI) to read from ui_state. * Joystick UI leaves this at 0 — it drives its own message display. */ __attribute__((weak)) uint16_t ui_led_get_msg_count(void) { return 0; } /* * Does the heartbeat LED light on this pass? The cycle keeps running in * every mode, LEDS_HB_OFF included, because the unread indicator is lit from * inside it. */ static bool hb_should_light(uint16_t msg_count) { switch (zephcore_leds_hb_mode()) { case LEDS_HB_OFF: return false; case LEDS_HB_UNREAD: return msg_count > 0; /* dark unless there is news */ default: return true; /* LEDS_HB_ALL, LEDS_HB_HB */ } } /* Pulse width for this pass. LEDS_HB_HB is the "liveness tick only" mode, so * it never widens even when messages are waiting. */ static uint16_t hb_pulse_ms(uint16_t msg_count) { if (zephcore_leds_hb_mode() == LEDS_HB_HB) { return LED_ON_MS; } return (msg_count > 0) ? LED_ON_MSG_MS : LED_ON_MS; } static void led_off_work_handler(struct k_work *work) { ARG_UNUSED(work); /* Yield the pin if radio activity is holding it (shared-pin boards only; * everywhere else this always reads false). Clearing here would blank the * LED in the middle of a transmit. Routed through heartbeat_led_write() * rather than a direct gpio_pin_set_dt(), since s_heartbeat_led doesn't * even exist on PWM-capable boards (see HAS_HEARTBEAT_LED_PWM above). */ if (!zephcore_led_radio_holds_pin()) { heartbeat_led_write(false); } #if HAS_MSG_LED gpio_pin_set_dt(&s_msg_led, 0); #endif uint16_t on_ms = hb_pulse_ms(ui_led_get_msg_count()); k_work_reschedule(&s_led_on_work, K_MSEC(LED_CYCLE_MS - on_ms)); } static void led_on_work_handler(struct k_work *work) { ARG_UNUSED(work); uint16_t mc = ui_led_get_msg_count(); uint16_t on_ms = hb_pulse_ms(mc); if (!zephcore_leds_disabled()) { if (hb_should_light(mc) && !zephcore_led_radio_holds_pin()) { heartbeat_led_write(true); } #if HAS_MSG_LED /* The unread LED is a separate pin, so it is governed by the mode * but not by the radio's hold on the heartbeat pin. LEDS_HB_HB is * the liveness-only mode and deliberately suppresses it. */ if (mc > 0 && zephcore_leds_hb_mode() != LEDS_HB_OFF && zephcore_leds_hb_mode() != LEDS_HB_HB) { gpio_pin_set_dt(&s_msg_led, 1); } #endif } k_work_reschedule(&s_led_off_work, K_MSEC(on_ms)); } #endif /* HAS_ANY_HEARTBEAT_LED */ /* * Weak: called after s_leds_disabled changes so each UI variant can sync its * own display state. Overridden by ui_task.c (button UI) to update * ui_state->leds_disabled so the LEDs page shows the correct toggle state. */ __attribute__((weak)) void ui_led_on_disabled_changed(bool disabled) { ARG_UNUSED(disabled); } void ui_led_heartbeat_init(void) { #if HAS_ANY_HEARTBEAT_LED if (heartbeat_led_is_ready()) { #if HAS_HEARTBEAT_LED gpio_pin_configure_dt(&s_heartbeat_led, GPIO_OUTPUT_INACTIVE); #else heartbeat_led_write(false); #endif k_work_init_delayable(&s_led_on_work, led_on_work_handler); k_work_init_delayable(&s_led_off_work, led_off_work_handler); k_work_reschedule(&s_led_on_work, K_NO_WAIT); LOG_INF("LED heartbeat started"); } #if HAS_MSG_LED if (gpio_is_ready_dt(&s_msg_led)) { gpio_pin_configure_dt(&s_msg_led, GPIO_OUTPUT_INACTIVE); LOG_INF("msg LED ready"); } #endif #endif } void ui_set_heartbeat_led(bool enabled) { #if HAS_ANY_HEARTBEAT_LED if (enabled && !zephcore_leds_disabled()) { if (heartbeat_led_is_ready()) { k_work_reschedule(&s_led_on_work, K_NO_WAIT); } } else { k_work_cancel_delayable(&s_led_on_work); k_work_cancel_delayable(&s_led_off_work); heartbeat_led_write(false); #if HAS_MSG_LED gpio_pin_set_dt(&s_msg_led, 0); #endif } #else (void)enabled; #endif } /* * Strong override of the weak hook in led_gate.c: react to a gate change from * anywhere (UI toggle, "set leds", boot). Stops or restarts the heartbeat cycle * and refreshes the UI's LED page. The gate flag itself is already set by the * time we get here — do NOT call back into ui_set_leds_disabled() from here. */ void zephcore_leds_ui_sync(bool disabled) { #if HAS_ANY_HEARTBEAT_LED if (disabled) { k_work_cancel_delayable(&s_led_on_work); k_work_cancel_delayable(&s_led_off_work); heartbeat_led_write(false); #if HAS_MSG_LED gpio_pin_set_dt(&s_msg_led, 0); #endif } else if (!k_work_delayable_is_pending(&s_led_on_work) && !k_work_delayable_is_pending(&s_led_off_work)) { /* Restart heartbeat only if it was stopped (avoids spurious pulse) */ if (heartbeat_led_is_ready()) { k_work_reschedule(&s_led_on_work, K_NO_WAIT); } } #else (void)disabled; #endif ui_led_on_disabled_changed(disabled); } /* UI-facing spelling of the same thing. Kept because the UI toggle pages and * the companion boot path call it by this name; the gate is what actually * governs every LED. */ void ui_set_leds_disabled(bool disabled) { zephcore_leds_set_disabled(disabled); } /* Flash the heartbeat LED immediately on message receipt. * Cancels the current cycle, pulses at LED_ON_MSG_MS width, then the * work chain resumes the normal heartbeat automatically. * No-op when LEDs are disabled or hardware is absent. */ void ui_led_flash_msg(void) { #if HAS_ANY_HEARTBEAT_LED if (!zephcore_leds_disabled() && heartbeat_led_is_ready()) { k_work_cancel_delayable(&s_led_on_work); k_work_cancel_delayable(&s_led_off_work); heartbeat_led_write(true); k_work_reschedule(&s_led_off_work, K_MSEC(LED_ON_MSG_MS)); } #endif } /* Flash the heartbeat LED 3 times on shutdown. * Used as a visual power-off indicator when the buzzer is muted. * Suppressed by "set leds off" — a node the user asked to keep dark stays dark * even at power-off. */ void ui_led_flash_shutdown(void) { #if HAS_ANY_HEARTBEAT_LED if (!zephcore_leds_disabled() && heartbeat_led_is_ready()) { for (int i = 0; i < 3; i++) { heartbeat_led_write(true); k_sleep(K_MSEC(100)); heartbeat_led_write(false); if (i < 2) { k_sleep(K_MSEC(100)); } } } #endif } /* ========== Battery refresh ========== * Lazy: render path calls ui_refresh_battery(); ADC only fires when the * cached reading is older than UI_BATT_REFRESH_MS. Telemetry / stats paths * read fresh directly via their own callbacks — this gate only governs the * local display. */ #define UI_BATT_REFRESH_MS 30000 static uint16_t (*s_batt_provider)(void); /* The board's own percentage: its discharge curve, or the fuel gauge. */ static uint8_t (*s_batt_pct_provider)(void); static uint32_t s_batt_last_read_ms; static bool s_batt_ever_read; void ui_set_battery_provider(uint16_t (*mv)(void), uint8_t (*pct)(void)) { s_batt_provider = mv; s_batt_pct_provider = pct; } void ui_refresh_battery(void) { if (!s_batt_provider) { return; } uint32_t now = k_uptime_get_32(); if (s_batt_ever_read && (now - s_batt_last_read_ms) < UI_BATT_REFRESH_MS) { return; } uint16_t mv = s_batt_provider(); ui_set_battery(mv, (mv && s_batt_pct_provider) ? s_batt_pct_provider() : 0); s_batt_last_read_ms = k_uptime_get_32(); s_batt_ever_read = true; } /* Forget the freshness timestamp so the next ui_refresh_battery() call is * guaranteed to sample the ADC. Use when entering a state where a fresh * reading matters (e.g. just woke the screen from sleep). */ void ui_invalidate_battery_cache(void) { s_batt_ever_read = false; s_batt_last_read_ms = 0; } /* ========== Power-off: the UI's part ========== * zephcore_power_off() (adapters/board/zephyr_poweroff.c) runs this first, * then powers the board down. */ void ui_before_power_off(void) { /* Stop heartbeat LED cycle (cancels both works). */ ui_set_heartbeat_led(false); /* Display off — content stays visible on EPD, blanks on OLED. */ #ifdef CONFIG_ZEPHCORE_UI_DISPLAY mc_display_off(); #endif } /* ========== Low-battery warning screen ========== * The policy itself is app/PowerPolicy.cpp. */ #ifdef CONFIG_ZEPHCORE_UI_DISPLAY void ui_show_low_battery(bool hold) { /* Wake the panel (OLED may be blanked by auto-off; EPD is always * visible). Centre two lines; the message persists on e-paper after * power is cut, so e-paper needs no hold delay. */ mc_display_on(); mc_display_clear(); const char *l1 = "Low Battery"; const char *l2 = "Shutting Down"; uint16_t w = mc_display_width(); uint8_t fw = mc_display_font_width(); uint8_t fh = mc_display_font_height(); int x1 = (fw && w) ? ((int)w - (int)strlen(l1) * fw) / 2 : 0; int x2 = (fw && w) ? ((int)w - (int)strlen(l2) * fw) / 2 : 0; if (x1 < 0) x1 = 0; if (x2 < 0) x2 = 0; int y1 = (int)mc_display_height() / 2 - (int)fh; if (y1 < 0) y1 = 0; mc_display_text(x1, y1, l1, false); mc_display_text(x2, y1 + fh + 2, l2, false); mc_display_finalize(); /* OLED blanks the instant power drops, so hold long enough to read it. * EPD keeps the image with no power, so skip the delay. The deferred- * poweroff (grace) path passes hold=false: it must NOT block the main * thread, because that thread has to service the app's message fetch * during the grace window — the grace timer provides the on-screen dwell * instead. */ if (hold && !mc_display_is_epd()) { k_sleep(K_MSEC(3000)); } } #else void ui_show_low_battery(bool hold) { ARG_UNUSED(hold); } #endif /* ========== Shared splash logo ========== * 128×13 ZephCore wordmark, MSB-first row-major (Adafruit XBM/drawBitmap * format). Used by both UI variants' splash renders via mc_display_xbm(). */ const uint8_t zephcore_logo[] = { 0x00, 0x01, 0xff, 0x7f, 0xe7, 0xf8, 0x70, 0x70, 0x3c, 0x01, 0xe0, 0x7f, 0xc3, 0xff, 0x00, 0x00, 0x00, 0x01, 0xff, 0x7f, 0xe7, 0xfc, 0x70, 0x70, 0xff, 0x07, 0xf8, 0x7f, 0xe3, 0xff, 0x00, 0x00, 0x00, 0x01, 0xff, 0x7f, 0xe7, 0xfe, 0x70, 0x71, 0xff, 0x0f, 0xfc, 0x7f, 0xf3, 0xff, 0x00, 0x00, 0x00, 0x00, 0x0e, 0x70, 0x07, 0x0e, 0x70, 0x71, 0xc7, 0x8e, 0x1c, 0x70, 0x73, 0x80, 0x00, 0x00, 0x00, 0x00, 0x1c, 0x70, 0x07, 0x0e, 0x70, 0x73, 0x83, 0x1c, 0x0e, 0x70, 0x73, 0x80, 0x00, 0x00, 0x00, 0x00, 0x38, 0x7f, 0xe7, 0xfe, 0x7f, 0xf3, 0x80, 0x1c, 0x0e, 0x7f, 0xf3, 0xff, 0x00, 0x00, 0x00, 0x00, 0x78, 0x7f, 0xe7, 0xfc, 0x7f, 0xf3, 0x80, 0x1c, 0x0e, 0x7f, 0xe3, 0xff, 0x00, 0x00, 0x00, 0x00, 0x70, 0x7f, 0xe7, 0xf8, 0x7f, 0xf3, 0x80, 0x1c, 0x0e, 0x7f, 0x83, 0xff, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x70, 0x07, 0x00, 0x70, 0x73, 0x83, 0x1c, 0x0e, 0x73, 0xc3, 0x80, 0x00, 0x00, 0x00, 0x01, 0xc0, 0x70, 0x07, 0x00, 0x70, 0x71, 0xc7, 0x8e, 0x1c, 0x71, 0xe3, 0x80, 0x00, 0x00, 0x00, 0x03, 0xff, 0x7f, 0xe7, 0x00, 0x70, 0x71, 0xff, 0x0f, 0xfc, 0x70, 0xe3, 0xff, 0x00, 0x00, 0x00, 0x03, 0xff, 0x7f, 0xe7, 0x00, 0x70, 0x70, 0xff, 0x07, 0xf8, 0x70, 0xf3, 0xff, 0x00, 0x00, 0x00, 0x03, 0xff, 0x7f, 0xe7, 0x00, 0x70, 0x70, 0x3c, 0x01, 0xe0, 0x70, 0x7b, 0xff, 0x00, 0x00, };