src.nth.io/

summaryrefslogtreecommitdiff
path: root/main
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-06-13 23:15:27 -0500
committerLuke Hoersten <[email protected]>2026-06-13 23:15:27 -0500
commitfdb8a1299eafdedf68902c4c785f9fb0390e80c4 (patch)
treef15376e132de8feef61023a511f0a313943a0fd7 /main
parent1d6cbb222ced226fcb482c0b19130774d181c8f8 (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.c18
-rw-r--r--main/button.c119
-rw-r--r--main/button.h8
-rw-r--r--main/local_screens.c175
-rw-r--r--main/local_screens.h25
-rw-r--r--main/state_machine.c6
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 {