mirror of
https://github.com/liquidraver/ZephCore.git
synced 2026-10-15 20:19:56 +00:00
- 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
448 lines
14 KiB
C
448 lines
14 KiB
C
/*
|
||
* 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,
|
||
};
|