Files
ZephCore/zephcore/helpers/ui/ui_common.c
T
liquidraver 059022403b ui: admin telemetry decoder, battery % from board curves, PowerPolicy
- admin screen: telemetry via upstream LPPDataHelpers (ported; fixed its
  1-byte humidity size), every field, per-channel, signed current
  (helpers/ui/telemetry_text)
- battery %: both UIs show ZephyrBoard::getBattPercent() (discharge
  curve / fuel gauge) instead of a linear 3.0-4.2 V estimate
- app/PowerPolicy: low-battery auto-shutdown + v-contact alert +
  autoshutdown CLI on every companion (headless too); under 2000 mV
  counts as no battery; default 3200 mV, stored 3300 migrated once
- one time_sync.c for both UIs; ui_push_radio_state() for all roles
- repeater admin screen in its own file; handleInput split per mode
- dead UI code removed; companion help fits one reply frame
2026-09-25 20:49:48 +02:00

448 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/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 so an upside-down mount only has to be
* configured once. Written from the mesh/CLI thread, read from the input
* callback. A plain bool needs no atomic here: it is a single aligned byte,
* and the only race — a keypress landing in the same instant the setting is
* toggled — costs that one keypress its direction, which is what toggling an
* axis swap does anyway. */
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 ========== */
/*
* Uses led0 (or led1 fallback) as a heartbeat indicator.
* Pulse width extends to LED_ON_MSG_MS when there are unread messages,
* driven by ui_led_get_msg_count() which the button variant overrides.
*
* led1 is also claimed as a message indicator in non-repeater companion builds
* when both led0 and led1 are present. The heartbeat cycle turns led1 on
* only when msg count > 0, giving a visual unread-message reminder.
*/
#if 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 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 1
#else
#define HAS_HEARTBEAT_LED 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_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_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? "unread" is not a separate blink
* — it is this same cycle widening its pulse — so the modes are expressed as
* two questions over one cycle: may it light at all right now, and how wide.
*
* The cycle keeps running in every mode including LEDS_HB_OFF. That is on
* purpose: on companions with two LEDs the unread indicator is lit from inside
* this work chain, so stopping the chain would take unread indication down with
* the heartbeat. An idle pass costs one work item every 4 s.
*/
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. */
if (!zephcore_led_radio_holds_pin()) {
gpio_pin_set_dt(&s_heartbeat_led, 0);
}
#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()) {
gpio_pin_set_dt(&s_heartbeat_led, 1);
}
#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_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_HEARTBEAT_LED
if (gpio_is_ready_dt(&s_heartbeat_led)) {
gpio_pin_configure_dt(&s_heartbeat_led, GPIO_OUTPUT_INACTIVE);
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_HEARTBEAT_LED
if (enabled && !zephcore_leds_disabled()) {
if (gpio_is_ready_dt(&s_heartbeat_led)) {
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);
gpio_pin_set_dt(&s_heartbeat_led, 0);
#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_HEARTBEAT_LED
if (disabled) {
k_work_cancel_delayable(&s_led_on_work);
k_work_cancel_delayable(&s_led_off_work);
gpio_pin_set_dt(&s_heartbeat_led, 0);
#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 (gpio_is_ready_dt(&s_heartbeat_led)) {
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_HEARTBEAT_LED
if (!zephcore_leds_disabled() && gpio_is_ready_dt(&s_heartbeat_led)) {
k_work_cancel_delayable(&s_led_on_work);
k_work_cancel_delayable(&s_led_off_work);
gpio_pin_set_dt(&s_heartbeat_led, 1);
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_HEARTBEAT_LED
if (!zephcore_leds_disabled() && gpio_is_ready_dt(&s_heartbeat_led)) {
for (int i = 0; i < 3; i++) {
gpio_pin_set_dt(&s_heartbeat_led, 1);
k_sleep(K_MSEC(100));
gpio_pin_set_dt(&s_heartbeat_led, 0);
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,
};