Files
ZephCore/zephcore/helpers/ui/ui_common.c
T
ZephCore T096andClaude Sonnet 5 fc0723a8ec led: one shared PWM write helper, shorter comments
The heartbeat and activity LED each computed the PWM pulse inline.
zephcore_led_pwm_write() in led_gate.c now does it once.

Comments added for the LED work described branch history, dates and
release notes; keep only what the code needs. led_gate.c still said the
brightness was RAM-only, which is no longer true, and the server decode
comment gave the old 10% default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 18:27:01 +02:00

471 lines
14 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 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 <zephyr_poweroff.h>
#include "led_gate.h" /* shared with the LoRa TX LED */
#include <zephyr/drivers/gpio.h>
#include <zephyr/drivers/pwm.h>
#include <zephyr/dt-bindings/input/input-event-codes.h>
#include <zephyr/kernel.h>
#include <string.h>
#include <zephyr/logging/log.h>
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 takes priority over the plain led0/led1 fallback. */
#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
#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)
{
zephcore_led_pwm_write(&s_heartbeat_led_pwm, on);
}
#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,
};