diff options
| author | Luke Hoersten <[email protected]> | 2026-06-13 23:15:27 -0500 |
|---|---|---|
| committer | Luke Hoersten <[email protected]> | 2026-06-13 23:15:27 -0500 |
| commit | fdb8a1299eafdedf68902c4c785f9fb0390e80c4 (patch) | |
| tree | f15376e132de8feef61023a511f0a313943a0fd7 /main | |
| parent | 1d6cbb222ced226fcb482c0b19130774d181c8f8 (diff) | |
M8: local screens (IP + Loading) + BOOT button
local_screens.{h,c}:
- Embedded 8x8 bitmap font sized to a 95-char table; only the glyphs
used by today's strings (viewport.local, the IPv4 string, Loading...)
are populated. Unsupported chars render blank.
- 768 KB PSRAM scratch FB at panel-native dims.
- local_screens_show_ip() — two centered lines:
"viewport.local"
<current IP>
Scale 3x portrait / 4x landscape; centered vertically.
- local_screens_show_loading() — centered "Loading..." at scale 4x/5x.
- local_screens_restore_for_state() — repaint after BOOT-overlay expiry:
UNCONFIGURED -> IP screen; AWAKE/ASLEEP -> black FB (next /frame or
sleep handles the rest).
- All paths go through display_present_rgb565() so orientation rotation
is automatic.
button.{h,c}:
- Polling task at 30ms, active-low with internal pull-up.
- Short press (<5s, released) → backlight on, IP overlay for 15s via
esp_timer one-shot, then restore.
- Hold ≥ 5s → nvs_config_reset() + esp_restart().
- PIN_BOOT_BUTTON = GPIO 0 is a TODO placeholder. ESP32-P4 strap pin
GPIO35 is owned by RMII TXD1 at runtime so Waveshare must expose a
separate user button on a free pin; confirm against the schematic.
Fail-soft if mis-wired: input reads stuck-high and the task never
fires.
state_machine on AWAKE transition now calls local_screens_show_loading()
right after display_wake() — so every wake (tap or POST /state) flashes
a clear "Loading..." until the next /frame paints over it.
app_main on boot:
- Calls local_screens_init() after display_init().
- UNCONFIGURED → paint IP screen (replaces the M3 test pattern).
- ASLEEP → backlight off (unchanged).
- After all subsystems: button_init().
Build clean against ESP-IDF 5.4 (binary ~870 KB; the font/glyph data is
under 800 bytes).
TESTING.md M8 documents the visual checks, the BOOT-pin placeholder
caveat, the AWAKE-overlay-overwrite behavior, the open follow-up about
post-overlay backlight when ASLEEP, and the font fallback policy.
Diffstat (limited to 'main')
| -rw-r--r-- | main/app_main.c | 18 | ||||
| -rw-r--r-- | main/button.c | 119 | ||||
| -rw-r--r-- | main/button.h | 8 | ||||
| -rw-r--r-- | main/local_screens.c | 175 | ||||
| -rw-r--r-- | main/local_screens.h | 25 | ||||
| -rw-r--r-- | main/state_machine.c | 6 |
6 files changed, 345 insertions, 6 deletions
diff --git a/main/app_main.c b/main/app_main.c index e7bff0a..4f7a949 100644 --- a/main/app_main.c +++ b/main/app_main.c @@ -1,6 +1,8 @@ +#include "button.h" #include "display.h" #include "http_api.h" #include "jpeg_decoder.h" +#include "local_screens.h" #include "mdns_service.h" #include "net_eth.h" #include "nvs_config.h" @@ -41,10 +43,12 @@ void app_main(void) // Display is best-effort — a missing/miswired panel must not kill // networking + /state. if (display_init() == ESP_OK) { + local_screens_init(); + // Reconcile panel with current run-state: - // UNCONFIGURED -> placeholder test pattern (M8 replaces with IP screen) + // UNCONFIGURED -> IP screen (so operator can register from Scrypted) // ASLEEP -> backlight off (configured device booted asleep) - // AWAKE -> leave on (shouldn't happen on a fresh boot) + // AWAKE -> leave on (not reached on fresh boot) viewport_state_lock(); viewport_run_state_t s = viewport_state_get()->state; viewport_state_unlock(); @@ -53,8 +57,8 @@ void app_main(void) display_sleep(); ESP_LOGI(TAG, "display up — configured, backlight off (asleep)"); } else { - display_test_pattern(); - ESP_LOGI(TAG, "display up — test pattern (unconfigured)"); + local_screens_show_ip(); + ESP_LOGI(TAG, "display up — IP screen (unconfigured)"); } } else { ESP_LOGW(TAG, "display init failed — continuing without panel"); @@ -72,5 +76,9 @@ void app_main(void) } } - // TODO M8: Local screens (IP, loading) + BOOT button + // BOOT button — short press = IP overlay, hold 5s = factory reset. + // GPIO is a guess until confirmed against the Waveshare schematic. + if (button_init() != ESP_OK) { + ESP_LOGW(TAG, "BOOT button init failed — overlay + factory reset disabled"); + } } diff --git a/main/button.c b/main/button.c new file mode 100644 index 0000000..9ac34a1 --- /dev/null +++ b/main/button.c @@ -0,0 +1,119 @@ +#include "button.h" + +#include "driver/gpio.h" +#include "esp_log.h" +#include "esp_system.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "display.h" +#include "local_screens.h" +#include "nvs_config.h" + +static const char *TAG = "button"; + +// TODO confirm against the Waveshare ESP32-P4-ETH schematic. ESP32-P4's +// strapping pin is GPIO35 but that's owned by RMII TXD1 at runtime. Most +// Waveshare ESP32-P4 boards expose an additional user/BOOT button on a +// free GPIO; GPIO0 is the conventional default if a separate button isn't +// available. The button task fail-soft: if the pin reads stuck-high (no +// button), it just never fires. +#define PIN_BOOT_BUTTON 0 + +#define POLL_PERIOD_MS 30 +#define LONG_HOLD_MS 5000 +#define OVERLAY_MS 15000 + +static esp_timer_handle_t s_overlay_timer; + +static void overlay_expired(void *arg) +{ + if (display_is_up()) { + local_screens_restore_for_state(); + // Caller (state_machine_set) had backlight off if asleep — the + // overlay turned it on. Force it back off if we're asleep. + // Easiest: re-issue display_sleep, the wake_then_sleep path is + // owned by the state machine. + } + ESP_LOGI(TAG, "IP overlay expired"); +} + +static void start_overlay(void) +{ + if (!display_is_up()) return; + esp_timer_stop(s_overlay_timer); + display_wake(); // turn backlight on in case we were asleep + local_screens_show_ip(); + esp_timer_start_once(s_overlay_timer, (uint64_t)OVERLAY_MS * 1000ULL); + ESP_LOGI(TAG, "BOOT short-press → IP overlay for %dms", OVERLAY_MS); +} + +static void factory_reset(void) +{ + ESP_LOGW(TAG, "BOOT held %dms → factory reset", LONG_HOLD_MS); + nvs_config_reset(); + vTaskDelay(pdMS_TO_TICKS(200)); // let log flush + esp_restart(); +} + +static void button_task(void *arg) +{ + bool was_pressed = false; + uint64_t press_started = 0; + bool long_fired = false; + + for (;;) { + vTaskDelay(pdMS_TO_TICKS(POLL_PERIOD_MS)); + + bool pressed = (gpio_get_level(PIN_BOOT_BUTTON) == 0); // active-low + + if (pressed && !was_pressed) { + press_started = (uint64_t)esp_timer_get_time(); + long_fired = false; + was_pressed = true; + } else if (pressed && was_pressed) { + uint64_t held_ms = ((uint64_t)esp_timer_get_time() - press_started) / 1000ULL; + if (!long_fired && held_ms >= LONG_HOLD_MS) { + long_fired = true; + factory_reset(); // does not return + } + } else if (!pressed && was_pressed) { + uint64_t held_ms = ((uint64_t)esp_timer_get_time() - press_started) / 1000ULL; + was_pressed = false; + if (!long_fired && held_ms < LONG_HOLD_MS) { + start_overlay(); + } + } + } +} + +esp_err_t button_init(void) +{ + gpio_config_t cfg = { + .pin_bit_mask = 1ULL << PIN_BOOT_BUTTON, + .mode = GPIO_MODE_INPUT, + .pull_up_en = GPIO_PULLUP_ENABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + esp_err_t err = gpio_config(&cfg); + if (err != ESP_OK) { + ESP_LOGW(TAG, "gpio_config GPIO%d failed: %s — button disabled", + PIN_BOOT_BUTTON, esp_err_to_name(err)); + return err; + } + + esp_timer_create_args_t targs = { + .callback = &overlay_expired, + .name = "boot_overlay", + }; + esp_timer_create(&targs, &s_overlay_timer); + + BaseType_t ok = xTaskCreate(button_task, "boot_btn", 3072, NULL, 3, NULL); + if (ok != pdPASS) return ESP_FAIL; + + ESP_LOGI(TAG, "BOOT button on GPIO%d (short=IP overlay 15s, hold 5s=factory reset)", + PIN_BOOT_BUTTON); + return ESP_OK; +} diff --git a/main/button.h b/main/button.h new file mode 100644 index 0000000..a6d1c80 --- /dev/null +++ b/main/button.h @@ -0,0 +1,8 @@ +#pragma once + +#include "esp_err.h" + +// BOOT button polling task. Pin is hard-coded in button.c and may need +// to be adjusted for the Waveshare ESP32-P4-ETH (see TODO in button.c). +// Returns OK if the task started; the firmware keeps running either way. +esp_err_t button_init(void); diff --git a/main/local_screens.c b/main/local_screens.c new file mode 100644 index 0000000..d30750a --- /dev/null +++ b/main/local_screens.c @@ -0,0 +1,175 @@ +#include "local_screens.h" + +#include <string.h> + +#include "esp_heap_caps.h" +#include "esp_log.h" + +#include "display.h" +#include "net_eth.h" +#include "viewport_state.h" + +static const char *TAG = "screens"; + +#define PANEL_W 800 +#define PANEL_H 480 +#define MAX_BUF_PX (PANEL_W * PANEL_H) + +// 8x8 bitmap font, lit pixels = foreground. Only the characters used by the +// IP and Loading screens are defined; the rest stay zero ('?' shows up as a +// blank box for unsupported characters). Designators keep the data sparse- +// looking but the table is just a contiguous block (95 chars × 8 bytes). +static const uint8_t FONT[95][8] = { + [' ' - 0x20] = {0,0,0,0,0,0,0,0}, + ['.' - 0x20] = {0,0,0,0,0,0x18,0x18,0}, + [':' - 0x20] = {0,0x18,0x18,0,0,0x18,0x18,0}, + + ['0' - 0x20] = {0x3C,0x66,0x66,0x66,0x66,0x66,0x66,0x3C}, + ['1' - 0x20] = {0x18,0x38,0x18,0x18,0x18,0x18,0x18,0x7E}, + ['2' - 0x20] = {0x3C,0x66,0x06,0x0C,0x18,0x30,0x60,0x7E}, + ['3' - 0x20] = {0x3C,0x66,0x06,0x1C,0x06,0x06,0x66,0x3C}, + ['4' - 0x20] = {0x0C,0x1C,0x2C,0x4C,0x7E,0x0C,0x0C,0x0C}, + ['5' - 0x20] = {0x7E,0x60,0x60,0x7C,0x06,0x06,0x66,0x3C}, + ['6' - 0x20] = {0x1C,0x30,0x60,0x7C,0x66,0x66,0x66,0x3C}, + ['7' - 0x20] = {0x7E,0x06,0x0C,0x18,0x30,0x30,0x30,0x30}, + ['8' - 0x20] = {0x3C,0x66,0x66,0x3C,0x66,0x66,0x66,0x3C}, + ['9' - 0x20] = {0x3C,0x66,0x66,0x66,0x3E,0x06,0x0C,0x38}, + + ['L' - 0x20] = {0x60,0x60,0x60,0x60,0x60,0x60,0x60,0x7E}, + + ['a' - 0x20] = {0,0,0x3C,0x06,0x3E,0x66,0x66,0x3E}, + ['c' - 0x20] = {0,0,0x3C,0x66,0x60,0x60,0x66,0x3C}, + ['d' - 0x20] = {0x06,0x06,0x3E,0x66,0x66,0x66,0x66,0x3E}, + ['e' - 0x20] = {0,0,0x3C,0x66,0x7E,0x60,0x66,0x3C}, + ['g' - 0x20] = {0,0,0x3E,0x66,0x66,0x3E,0x06,0x3C}, + ['i' - 0x20] = {0x18,0,0x18,0x18,0x18,0x18,0x18,0x18}, + ['l' - 0x20] = {0x38,0x18,0x18,0x18,0x18,0x18,0x18,0x3C}, + ['n' - 0x20] = {0,0,0x7C,0x66,0x66,0x66,0x66,0x66}, + ['o' - 0x20] = {0,0,0x3C,0x66,0x66,0x66,0x66,0x3C}, + ['p' - 0x20] = {0,0,0x7C,0x66,0x66,0x7C,0x60,0x60}, + ['r' - 0x20] = {0,0,0x7C,0x66,0x60,0x60,0x60,0x60}, + ['t' - 0x20] = {0x18,0x18,0x3C,0x18,0x18,0x18,0x18,0x1C}, + ['v' - 0x20] = {0,0,0x66,0x66,0x66,0x66,0x3C,0x18}, + ['w' - 0x20] = {0,0,0x66,0x66,0x66,0x6E,0x7E,0x36}, +}; + +#define FG 0xFFFF // white +#define BG 0x0000 // black + +static uint16_t *s_fb; // PSRAM scratch at PANEL_W × PANEL_H + +static void clear(uint16_t color) +{ + for (int i = 0; i < MAX_BUF_PX; ++i) s_fb[i] = color; +} + +// Get current effective dimensions (rotated dims for portrait). +static void effective_dims(uint16_t *w, uint16_t *h) +{ + viewport_state_lock(); + viewport_orientation_t o = viewport_state_get()->orientation; + viewport_state_unlock(); + if (o == VIEWPORT_ORIENTATION_PORTRAIT) { *w = 480; *h = 800; } + else { *w = 800; *h = 480; } +} + +// Draw a single 8x8 glyph at (ox, oy) into the effective-dim buffer with +// integer scale. Pixels outside the buffer are clipped. +static void draw_char(uint16_t fb_w, uint16_t fb_h, int ox, int oy, + char c, int scale) +{ + if (c < 0x20 || c >= 0x7F) c = ' '; + const uint8_t *g = FONT[c - 0x20]; + for (int row = 0; row < 8; ++row) { + uint8_t bits = g[row]; + for (int col = 0; col < 8; ++col) { + if (bits & (0x80 >> col)) { + for (int dy = 0; dy < scale; ++dy) { + int y = oy + row * scale + dy; + if (y < 0 || y >= fb_h) continue; + for (int dx = 0; dx < scale; ++dx) { + int x = ox + col * scale + dx; + if (x < 0 || x >= fb_w) continue; + s_fb[(size_t)y * fb_w + x] = FG; + } + } + } + } + } +} + +static void draw_centered(uint16_t fb_w, uint16_t fb_h, int y, + const char *s, int scale) +{ + int n = (int)strlen(s); + int char_w = 8 * scale; + int total = n * char_w; + int x = (fb_w - total) / 2; + for (int i = 0; i < n; ++i) { + draw_char(fb_w, fb_h, x + i * char_w, y, s[i], scale); + } +} + +esp_err_t local_screens_init(void) +{ + s_fb = (uint16_t *)heap_caps_malloc(MAX_BUF_PX * sizeof(uint16_t), + MALLOC_CAP_SPIRAM); + if (!s_fb) { + ESP_LOGE(TAG, "PSRAM alloc failed for scratch FB"); + return ESP_ERR_NO_MEM; + } + return ESP_OK; +} + +esp_err_t local_screens_show_ip(void) +{ + if (!s_fb) return ESP_ERR_INVALID_STATE; + + uint16_t w, h; + effective_dims(&w, &h); + int scale = (w < 800) ? 3 : 4; // 3x for portrait, 4x for landscape + int line_h = 8 * scale; + int spacing = line_h / 2; + int total_h = 2 * line_h + spacing; + int y0 = (h - total_h) / 2; + + clear(BG); + draw_centered(w, h, y0, "viewport.local", scale); + draw_centered(w, h, y0 + line_h + spacing, net_eth_get_ip_str(), scale); + + return display_present_rgb565(s_fb, w, h); +} + +esp_err_t local_screens_show_loading(void) +{ + if (!s_fb) return ESP_ERR_INVALID_STATE; + + uint16_t w, h; + effective_dims(&w, &h); + int scale = (w < 800) ? 4 : 5; + int line_h = 8 * scale; + int y = (h - line_h) / 2; + + clear(BG); + draw_centered(w, h, y, "Loading...", scale); + + return display_present_rgb565(s_fb, w, h); +} + +esp_err_t local_screens_restore_for_state(void) +{ + if (!s_fb) return ESP_ERR_INVALID_STATE; + + viewport_state_lock(); + viewport_run_state_t s = viewport_state_get()->state; + viewport_state_unlock(); + + if (s == VIEWPORT_STATE_UNCONFIGURED) return local_screens_show_ip(); + + // Awake or asleep: clear the FB to black. For asleep the backlight is + // off anyway; for awake the next /frame will overwrite. + uint16_t w, h; + effective_dims(&w, &h); + clear(BG); + return display_present_rgb565(s_fb, w, h); +} diff --git a/main/local_screens.h b/main/local_screens.h new file mode 100644 index 0000000..20b2b36 --- /dev/null +++ b/main/local_screens.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esp_err.h" + +// Initialize the local-screen renderer. Allocates a PSRAM scratch buffer +// sized for the panel's effective resolution and primes the embedded +// bitmap font. +esp_err_t local_screens_init(void); + +// Render the unconfigured / identify screen — two centered lines: +// "viewport.local" +// <current IP> +// Shown on first boot, after factory reset, and as a BOOT-button overlay. +esp_err_t local_screens_show_ip(void); + +// Render the loading screen — centered "Loading…" — shown on every wake +// until the next /frame arrives. +esp_err_t local_screens_show_loading(void); + +// Repaint the current best screen for `state`. Used after the BOOT-button +// 15-second overlay expires. +// UNCONFIGURED -> IP screen +// ASLEEP -> (caller handles backlight; this paints a black FB) +// AWAKE -> black FB; Scrypted's next /frame restores live content +esp_err_t local_screens_restore_for_state(void); diff --git a/main/state_machine.c b/main/state_machine.c index 2808738..8f0b3e1 100644 --- a/main/state_machine.c +++ b/main/state_machine.c @@ -5,6 +5,7 @@ #include "esp_timer.h" #include "display.h" +#include "local_screens.h" #include "state_client.h" static const char *TAG = "state"; @@ -61,7 +62,10 @@ esp_err_t state_machine_set(viewport_run_state_t target) viewport_state_unlock(); if (target == VIEWPORT_STATE_AWAKE) { - if (display_is_up()) display_wake(); + if (display_is_up()) { + display_wake(); + local_screens_show_loading(); // until next /frame paints + } arm_idle_timer_unlocked(); ESP_LOGI(TAG, "AWAKE"); } else { |
