src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-06-14 17:37:51 -0500
committerLuke Hoersten <[email protected]>2026-06-14 17:37:51 -0500
commitcbc4904aa35191f59aff93df5ed36f09f4cfa943 (patch)
treeae854449c6665d1de2b418ec9bb10821b6ab5cef
parent18ddde394832beb812181003016ff73498ac72a3 (diff)
Info screen: full /config + /state dump, replaces identity screen
Previously the 4-line "identity" screen showed only viewport name, mDNS host, IP, and state. Expanded to 15 lines covering the full GET /config + GET /state output (name, host, ip, state, configured, scrypted, orientation, brightness, idle, fw, uptime, frames, errs, free heap, free PSRAM), label/value pairs left-aligned with auto-scaled font. Renames "identity" → "info" throughout — symbol, log messages, README/TESTING references. Also: - Move the info-screen render's big locals (~1.6 KiB: lines[16][80] + scrypt[256] + vp_name[64]) to BSS. The touch task's 3 KiB stack was overflowing on every long-press, leaving whatever frame was previously on the panel — gave the appearance of a "blue screen" after the M5 test pattern. - Drop the ≥5 s touch factory-reset gesture and remove all stale references to it in docs and comments. NVS wipe is now a USB-side `idf.py erase-flash` operation only. - M5 follow-up TODO in jpeg_decoder.c: solid green renders ~black and solid blue renders green; not a byte-order issue (BGR setting turns red into blue). Tracked as a known M5 gap.
-rw-r--r--README.md24
-rw-r--r--TESTING.md49
-rw-r--r--main/app_main.c6
-rw-r--r--main/jpeg_decoder.c5
-rw-r--r--main/local_screens.c182
-rw-r--r--main/local_screens.h23
-rw-r--r--main/state_machine.c4
-rw-r--r--main/touch.c6
8 files changed, 198 insertions, 101 deletions
diff --git a/README.md b/README.md
index e301c88..a142ee5 100644
--- a/README.md
+++ b/README.md
@@ -288,9 +288,9 @@ Don't design Scrypted-side logic that waits for the device to confirm a state ch
**When the device does NOT POST**
-- Before `/config` has registered a `scrypted` URL (boot state, factory reset). Silently dropped.
+- Before `/config` has registered a `scrypted` URL (boot state, NVS-erased). Silently dropped.
- For state changes Scrypted initiated (`POST /state`, `POST /frame` while asleep). Scrypted already knows; echoing would loop.
-- BOOT short-press IP overlay (not a state change).
+- Long-press info overlay (not a state change).
**Trust model**
@@ -334,16 +334,20 @@ Failure modes do not corrupt state:
- Network drop mid-stream: device idle-sleeps when the timer expires.
- Concurrent `/frame` posts: one wins, the other gets `503`; no half-painted frames.
-## BOOT button
+## Touch gestures
-- **Short press**: overlay the IP screen for 15 seconds, then return to the prior state. Useful for identifying or re-registering a device that's already configured. Wakes the backlight temporarily; does not change the wake/sleep state and does not POST to Scrypted. An incoming `/frame` while the overlay is showing is rejected with `409` (state is still "sleep" underneath).
-- **Hold ≥5s**: factory reset — clear NVS, reboot. The device comes back unconfigured, showing the IP screen until Scrypted POSTs `/config`.
+The board has no usable user button (GPIO 35 is owned by EMAC TXD1 at runtime), so both behaviours live on the touch panel:
+
+- **Short tap** (<500 ms): toggle wake / sleep. POSTs `/state` to Scrypted when configured.
+- **Long-press** (≥1.5 s): overlay the info screen for 15 seconds, then return to the prior state. Useful for identifying or re-registering a device that's already configured. Wakes the backlight temporarily; does not change the wake/sleep state and does not POST to Scrypted. An incoming `/frame` while the overlay is showing is rejected with `409` (state is still "sleep" underneath).
+
+There is no factory-reset gesture. To wipe NVS, plug USB and run `idf.py erase-flash` followed by a normal reflash.
## Local rendering
The device renders exactly two things itself; everything else is a JPEG from Scrypted:
-- **Identity screen**: four centered lines — viewport name, mDNS hostname, IP address, and current state. Shown on first boot until `/config`, after factory reset, and as a 15s overlay when BOOT is short-pressed. The font scale auto-fits the longest line within 90% of the screen width. Unconfigured devices show `viewport` / `viewport.local` / `<ip>` / `unconfigured`; configured devices show e.g. `mudroom` / `viewport-mudroom.local` / `192.168.1.42` / `asleep`.
+- **Info screen**: ~15 lines of `label value` pairs (white on black, auto-scaled) covering the full `GET /config` + `GET /state` dump — name, host, ip, state, configured, scrypted, orientation, brightness, idle, firmware, uptime, frames, errors, free heap, free PSRAM. Shown on first boot until `/config`, on NVS erase, and as a 15 s overlay on a touch long-press.
- **Loading screen**: shown between a wake and the next `/frame` arriving. Plain "Loading…" text. Rendered in the current orientation.
Both use a small embedded bitmap font — full lowercase a–z, digits, period, colon, dash, slash, plus uppercase `L` for "Loading...". No LVGL, no general text engine.
@@ -421,7 +425,7 @@ Scrypted should use the same `idle_timeout_ms` value it sent in `/config` as its
- Firmware updates: reflash over USB. No OTA in v1 (planned post-v1: HTTP OTA from Scrypted).
- Provisioning: flash the same firmware to every device. On first boot the screen shows its IP; register it from Scrypted via `POST /config`.
- Viewport names must be unique across the LAN — mDNS hostnames are derived from `viewport` and two devices configured with the same name will collide.
-- Factory reset: hold BOOT for 5s during normal operation to clear NVS (viewport name, scrypted URL, brightness, idle timeout, orientation) and reboot. The device returns to the IP screen.
+- NVS wipe: plug USB and run `idf.py erase-flash` followed by `idf.py flash`. The device boots clean and shows the info screen until `/config` is POSTed.
- No DHCP lease: keep retrying; do not reboot. Screen shows "no network" if unconfigured.
- Ethernet disconnect: reconnect automatically. If Scrypted is unreachable, displays go stale — nothing the device can do about it.
- Watchdog: the ESP-IDF task watchdog reboots the device if a task hangs. Soft state is rebuilt from NVS on every boot.
@@ -443,7 +447,7 @@ idf.py -p /dev/cu.usbmodem* flash monitor
| File | What it does |
| --- | --- |
-| `app_main.c` | Boot sequence: NVS → state → Ethernet → mDNS → HTTP → state machine → state-client worker → display → JPEG decoder → touch → BOOT button. |
+| `app_main.c` | Boot sequence: NVS → state → Ethernet → mDNS → HTTP → state machine → state-client worker → display → JPEG decoder → touch. |
| `viewport_state.{h,c}` | Shared runtime state behind a FreeRTOS mutex; every module reads/writes through `viewport_state_lock`. |
| `nvs_config.{h,c}` | Persist viewport, scrypted URL, idle timeout, orientation, brightness to NVS. Load on boot; flip state UNCONFIGURED → ASLEEP when both name + URL are present. |
| `net_eth.{h,c}` | Internal EMAC + IP101GRI PHY init, DHCP wait, IP getter. Pin map verified against Waveshare wiki + ESPHome's working config. |
@@ -455,7 +459,7 @@ idf.py -p /dev/cu.usbmodem* flash monitor
| `state_client.{h,c}` | Worker task + `xQueueOverwrite()` depth-1 queue for outbound POSTs to `<scrypted>/state`. 1 s timeout, fire-and-forget, `state_post_failures` counter. |
| `touch.{h,c}` | FT5426 polling at 30 ms over the shared I²C bus. Tap = down→up within 500 ms, 150 ms debounce. |
| `local_screens.{h,c}` | 8×8 bitmap font (sparse 95-char table populated for the IP and Loading strings), centered text rendering at scale 3×/4× into a PSRAM scratch FB, routed through `display_present_rgb565()` so orientation is automatic. |
-| `button.{h,c}` | BOOT button polling. Short press → 15 s IP-screen overlay. ≥ 5 s hold → `nvs_config_reset()` + `esp_restart()`. |
+| `touch.{h,c}` | FT5426 polling. Short tap → toggle wake/sleep. ≥1.5 s hold → 15 s info-screen overlay. (Replaced `button.{h,c}` — no usable hardware button.) |
### Memory strategy
@@ -489,7 +493,7 @@ Every endpoint is idempotent; every failure leaves the device in a sane state.
In rough priority order. Each entry links the relevant section so you can pick up cold.
1. **Hardware bring-up of M1 + M2** — flash the board and confirm DHCP + `GET /state` over Ethernet. No panel needed; ~10 minutes once the board is reachable. See [`TESTING.md` Stage 1](TESTING.md#stage-1--board-comes-alive-m1--m2).
-2. **Schematic confirmation** — open the Waveshare ESP32-P4-ETH schematic PDF and confirm: DSI FPC pin count, I²C GPIO mapping (`PIN_I2C_SDA=7`, `PIN_I2C_SCL=8` in `display.c` are placeholders), BOOT button GPIO (`PIN_BOOT_BUTTON=0` in `button.c` is a guess), flash chip size silkscreen. See [`TESTING.md` Hardware prerequisites](TESTING.md#hardware-prerequisites).
+2. **Schematic confirmation** — open the Waveshare ESP32-P4-ETH schematic PDF and confirm flash chip size silkscreen against `sdkconfig.defaults`. DSI FPC pin count, I²C GPIOs (`PIN_I2C_SDA=7`, `PIN_I2C_SCL=8`), and BOOT button availability are already resolved on hardware — see [`TESTING.md` Hardware prerequisites](TESTING.md#hardware-prerequisites).
3. **Source the DSI adapter cable** — 15-pin Pi-FPC → Waveshare-side DSI. Order matches what step 2 reveals.
4. **Stage-by-stage bench verification (M3 → M8)** — work through [`TESTING.md` Recommended bench order](TESTING.md#recommended-bench-order) with the panel attached and jumpers wired. Each stage flips its milestones to ✅.
5. **End-to-end with Scrypted** — paste [`scrypted/scrypted-viewport.ts`](scrypted/scrypted-viewport.ts) into Scrypted's Scripts plugin, add a viewport device, trigger a bound camera, watch the panel light up. See [`scrypted/README.md`](scrypted/README.md).
diff --git a/TESTING.md b/TESTING.md
index 43bbe75..443cfa1 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -49,7 +49,7 @@ Hardware unknowns — resolved:
1. **DSI FPC pin count on the Waveshare board.** ✅ 22-pin DSI on board; bridge to the panel's 15-pin Pi FPC via a 15→22 adapter cable.
2. **I²C jumper destinations.** ✅ `PIN_I2C_SDA=7`, `PIN_I2C_SCL=8` confirmed by ATTINY ack at 0x45 + FT5426 ack at 0x38.
-3. **BOOT button GPIO.** ✅ No usable GPIO — ESP32-P4 GPIO 35 strap pin is owned by EMAC TXD1 at runtime; no separate user button is wired on the Waveshare ESP32-P4-ETH. Both BOOT behaviours moved onto touch long-press (≥1.5 s → identity overlay) and very-long-press (≥5 s → factory reset).
+3. **BOOT button GPIO.** ✅ No usable GPIO — ESP32-P4 GPIO 35 strap pin is owned by EMAC TXD1 at runtime; no separate user button is wired on the Waveshare ESP32-P4-ETH. Info-overlay behaviour moved onto touch long-press (≥1.5 s). No factory-reset gesture — that path goes through USB reflash + `idf.py erase-flash`.
4. **Flash size silkscreen.** `sdkconfig.defaults` declares 16 MB. If your SKU shipped with 32 MB the build still works but you waste half the flash.
---
@@ -71,7 +71,7 @@ Check serial for `net_eth: got ip` then `curl http://<ip>/state | jq .` and `dns
### Stage 2 — display panel (M3) ✅
-Look for `panel MCU id 0xc3 — Pi v1.1 architecture ack'd`, `TC358762 bridge configured`, and `DSI up: 800x480 26 MHz, 1-lane 600 Mbps, non-burst` in the serial log. Identity screen visible on tap-wake.
+Look for `panel MCU id 0xc3 — Pi v1.1 architecture ack'd`, `TC358762 bridge configured`, and `DSI up: 800x480 26 MHz, 1-lane 600 Mbps, non-burst` in the serial log. Info screen visible on tap-wake.
### Stage 3 — protocol (M4 + M5 + M6)
@@ -243,7 +243,7 @@ I (xxx) viewport: display up — test pattern on screen
- Image but vertical/horizontal sync issues → adjust the `PANEL_*SYNC_*` timings in `display.c`; Pi 7" canonical values are the defaults.
- Touch ack at `0x38` but no taps → polling task not running, or panel reset state wrong.
-**Status**: ✅ verified 2026-06-14 on Waveshare ESP32-P4-ETH + Hosyond 5" panel. ATTINY v1.1 firmware (ID 0xC3) acks; TC358762 bridge configured via 16 DSI Generic Long Writes; DSI 1-lane @ 600 Mbps non-burst; DPI 26 MHz RGB888; identity screen renders correctly. Bring-up notes captured in commit 6c1a26b.
+**Status**: ✅ verified 2026-06-14 on Waveshare ESP32-P4-ETH + Hosyond 5" panel. ATTINY v1.1 firmware (ID 0xC3) acks; TC358762 bridge configured via 16 DSI Generic Long Writes; DSI 1-lane @ 600 Mbps non-burst; DPI 26 MHz RGB888; info screen renders correctly. Bring-up notes captured in commit 6c1a26b.
---
@@ -469,7 +469,7 @@ curl http://<device-ip>/state | jq .state # expect "awake"
Unconfigured device rejects `POST /state` with 409:
```bash
-# (After factory reset / fresh boot, no /config yet)
+# (After fresh boot / NVS erase, no /config yet)
curl -i -X POST -H "Content-Type: application/json" \
-d '{"state":"wake"}' http://<device-ip>/state
# expect: 409 Conflict "device unconfigured"
@@ -554,51 +554,40 @@ Tap rapidly (faster than the receiver can ack) and confirm the receiver only see
**No POST when unconfigured**
-- After factory reset, tapping the screen does nothing (no Scrypted URL to call). No queue entries dropped to disk.
+- Before `/config` provides a Scrypted URL, tapping the screen does nothing outbound (no Scrypted URL to call). No queue entries dropped to disk.
-**Status**: ✅ verified 2026-06-14. FT5426 acks at 0x38; short tap toggles awake/sleep, ≥1.5s hold opens the identity overlay, ≥5s hold triggers factory reset. Outbound `/state` POST gated on configured flag (untested without Scrypted).
+**Status**: ✅ verified 2026-06-14. FT5426 acks at 0x38; short tap toggles awake/sleep, ≥1.5s hold opens the info overlay. Outbound `/state` POST gated on configured flag (untested without Scrypted).
---
-## M8 — Local Screens + BOOT button
+## M8 — Local Screens + touch long-press
-**Acceptance**: identity screen on first boot; loading screen on every wake; BOOT button works.
+**Acceptance**: info screen on first boot; loading screen on every wake; long-press shows the info overlay.
**Visual checks (panel-attached)**
-- Fresh flash (or after factory reset) → screen shows four centered white-on-black lines (auto-scaled to fit):
- ```
- viewport
- viewport.local
- 192.168.x.y
- unconfigured
- ```
+- Fresh flash → screen shows the info screen (~15 lines of `label value` pairs, white on black, auto-scaled). When unconfigured it reports `name unset`, `config no`, `state unconfigured`.
- `POST /config` with viewport + scrypted → device transitions to ASLEEP, backlight off.
- `POST /state {state:wake}` (or tap) → backlight on, `Loading...` centered until the first `/frame` lands.
- `POST /frame` while AWAKE → loading screen replaced by the JPEG.
-- **BOOT short-press while configured** → identity overlay for 15s showing four lines populated from current state, e.g.:
- ```
- mudroom
- viewport-mudroom.local
- 192.168.1.42
- asleep
- ```
+- **Long-press (≥1.5s)** at any state → info overlay for 15 s with the full current config + state dump (name, host, ip, state, config, scrypt, orient, bright, idle, fw, up, frames, errs, heap, psram).
- `POST /state {state:sleep}` (or idle timeout, or tap-while-awake) → backlight off.
-**BOOT button** (GPIO 0 placeholder — see Hardware note below)
+**Touch gestures** (no hardware button — see Hardware note)
-- Short press at any state: backlight wakes if it was off, identity screen overlays for 15s, then `local_screens_restore_for_state()` paints black (Scrypted's next `/frame` repaints if awake; the state machine handles backlight if asleep). Does not change wake/sleep state.
-- Hold ≥ 5s: serial logs `BOOT held 5000ms → factory reset`, NVS clears via `nvs_config_reset()`, `esp_restart()`. After reboot the device comes back UNCONFIGURED with the IP screen.
+- Short tap (<500 ms): toggle wake / sleep.
+- Long-press (≥1.5 s): info overlay for 15 s.
+- No factory-reset gesture. Use USB + `idf.py erase-flash` to clear NVS.
-**Hardware note**: `PIN_BOOT_BUTTON = 0` in `button.c` is a guess. ESP32-P4's official strap pin is GPIO35 but that's owned by RMII TXD1 at runtime. Most Waveshare ESP32-P4 boards expose a separate user button on a free GPIO — confirm against the schematic and update `PIN_BOOT_BUTTON` if needed. If the wrong pin is wired, the button just never triggers (input reads stuck-high) and the rest of M8 still works.
+**Hardware note**: ESP32-P4's strap pin GPIO35 is owned by EMAC TXD1 at runtime, and the Waveshare ESP32-P4-ETH doesn't expose any separate user button on a free GPIO. Both BOOT-button behaviours moved onto the touch panel (see M7).
**Negative / edge**
-- BOOT overlay during AWAKE: the overlay paints over the live frame. Scrypted's next `/frame` (within ~1s in a normal stream) overwrites it. Acceptable: the operator sees the identity for ≤ 1 frame interval then the live view resumes.
-- BOOT overlay during ASLEEP: backlight comes on for the overlay, then `restore_for_state` paints black. The state machine's `display_sleep()` is NOT re-issued — that's an open follow-up if the screen stays lit after expiry.
-- Font fallback: any character outside the supported set (digits, period, colon, dash, slash, `L`, lowercase a–z, space) renders as blank. The identity screen, loading screen, and IPv4 strings are all covered.
+- Info overlay during AWAKE: the overlay paints over the live frame. Scrypted's next `/frame` (within ~1s in a normal stream) overwrites it. Acceptable: the operator sees the info for ≤ 1 frame interval then the live view resumes.
+- Info overlay during ASLEEP: backlight comes on for the overlay, then expires back to sleep when the 15 s timer fires.
+- Font fallback: any character outside the supported set (digits, period, colon, dash, slash, `L`, lowercase a–z, space) renders as blank. Info-screen labels and IPv4 strings are all covered.
-**Status**: ✅ verified 2026-06-14. Identity screen renders all four lines correctly; loading screen shown on wake when configured; identity overlay returns to prior state when its 15 s timer expires. BOOT button removed — its short-press and long-hold behaviours now live on touch (see M7).
+**Status**: ✅ verified 2026-06-14. Info screen renders the full config + state dump; loading screen shown on wake when configured; info overlay returns to prior state when its 15 s timer expires.
---
diff --git a/main/app_main.c b/main/app_main.c
index 2cb395e..44e2052 100644
--- a/main/app_main.c
+++ b/main/app_main.c
@@ -72,9 +72,9 @@ void app_main(void)
// config is even slightly off; keeping that off the main task means
// a misconfigured panel can't take down networking + /state. The
// task also brings up the JPEG decoder and touch (touch shares the
- // panel I²C bus and also handles long-press → identity overlay and
- // very-long-press → factory reset, since the board's BOOT button is
- // wired to a strap pin (GPIO35) that the EMAC owns at runtime).
+ // panel I²C bus and also handles long-press → info overlay, since
+ // the board's BOOT button is wired to a strap pin (GPIO35) that the
+ // EMAC owns at runtime).
// ------------------------------------------------------------------
ESP_LOGI(TAG, "boot complete — net subsystems [%s] ip=%s; "
"display init deferred to dsp_init task",
diff --git a/main/jpeg_decoder.c b/main/jpeg_decoder.c
index b34612d..259af65 100644
--- a/main/jpeg_decoder.c
+++ b/main/jpeg_decoder.c
@@ -71,6 +71,11 @@ esp_err_t jpeg_decoder_decode(size_t jpeg_len,
jpeg_decode_cfg_t dec_cfg = {
.output_format = JPEG_DECODE_OUT_FORMAT_RGB565,
.rgb_order = JPEG_DEC_RGB_ELEMENT_ORDER_RGB,
+ // TODO: known color bug on M5 — solid red renders correctly but solid
+ // green renders ~black and solid blue renders green. Likely a JPEG
+ // colorspace / channel-permutation issue, not a byte-order one
+ // (swapping to BGR turns red into blue, confirming RGB is the right
+ // element ordering). Needs follow-up.
.conv_std = JPEG_YUV_RGB_CONV_STD_BT601,
};
diff --git a/main/local_screens.c b/main/local_screens.c
index 34c242c..4b639ef 100644
--- a/main/local_screens.c
+++ b/main/local_screens.c
@@ -72,6 +72,9 @@ static const uint8_t FONT[95][8] = {
#define FG 0xFFFF // white
#define BG 0x0000 // black
+#define INFO_MAX_LINES 16
+#define INFO_LINE_BYTES 80 // generous: viewport_name is 64 chars max
+
static uint16_t *s_fb; // PSRAM scratch at PANEL_W × PANEL_H
static esp_timer_handle_t s_overlay_timer;
static volatile bool s_overlay_active;
@@ -128,6 +131,17 @@ static void draw_centered(uint16_t fb_w, uint16_t fb_h, int y,
}
}
+// Draw a left-aligned line at (ox, oy).
+static void draw_left(uint16_t fb_w, uint16_t fb_h, int ox, int oy,
+ const char *s, int scale)
+{
+ int n = (int)strlen(s);
+ int char_w = 8 * scale;
+ for (int i = 0; i < n; ++i) {
+ draw_char(fb_w, fb_h, ox + i * char_w, oy, s[i], scale);
+ }
+}
+
static void overlay_expired_cb(void *arg);
esp_err_t local_screens_init(void)
@@ -140,7 +154,7 @@ esp_err_t local_screens_init(void)
}
esp_timer_create_args_t args = {
.callback = &overlay_expired_cb,
- .name = "ident_overlay",
+ .name = "info_overlay",
};
esp_timer_create(&args, &s_overlay_timer);
return ESP_OK;
@@ -166,7 +180,7 @@ esp_err_t local_screens_overlay(uint32_t duration_ms)
if (!display_is_up()) return ESP_ERR_INVALID_STATE;
esp_timer_stop(s_overlay_timer);
display_wake();
- esp_err_t err = local_screens_show_ip();
+ esp_err_t err = local_screens_show_info();
if (err != ESP_OK) return err;
s_overlay_active = true;
return esp_timer_start_once(s_overlay_timer,
@@ -185,60 +199,148 @@ void local_screens_overlay_dismiss(void)
if (display_is_up()) display_sleep();
}
-esp_err_t local_screens_show_ip(void)
+// Format `n` as a compact value with K / M suffix for thousands / millions.
+// Output max 8 chars, e.g. "168k", "30M", "1234".
+static void fmt_bytes(char *out, size_t cap, uint32_t n)
{
- if (!s_fb) return ESP_ERR_INVALID_STATE;
+ if (n >= 1000000) snprintf(out, cap, "%um", (unsigned)(n / 1000000));
+ else if (n >= 1000) snprintf(out, cap, "%uk", (unsigned)(n / 1000));
+ else snprintf(out, cap, "%u", (unsigned)n);
+}
+
+// Format an uptime in milliseconds as "h:mm:ss" or "m:ss".
+static void fmt_uptime(char *out, size_t cap, uint64_t ms)
+{
+ uint64_t s = ms / 1000;
+ unsigned hh = (unsigned)(s / 3600);
+ unsigned mm = (unsigned)((s % 3600) / 60);
+ unsigned ss = (unsigned)(s % 60);
+ if (hh > 0) snprintf(out, cap, "%u:%02u:%02u", hh, mm, ss);
+ else snprintf(out, cap, "%u:%02u", mm, ss);
+}
- // Build the four identity lines from current state. This is the screen
- // shown on first boot, after factory reset, and as a 15s BOOT-button
- // overlay — it's the "who am I" view the operator needs to find the
- // device on the LAN and confirm it's configured the way they expect.
- //
- // line 1: viewport name ("mudroom" / "viewport" if unconfigured)
- // line 2: mDNS hostname ("viewport-mudroom.local" or "viewport.local")
- // line 3: IP address ("192.168.x.y" or "no network")
- // line 4: state ("awake" / "asleep" / "unconfigured")
+esp_err_t local_screens_show_info(void)
+{
+ if (!s_fb) return ESP_ERR_INVALID_STATE;
- char line_name[64], line_host[80], line_ip[24], line_state[24];
+ // Snapshot every interesting value under a single lock acquisition so
+ // the render reflects a consistent point in time. The big buffers live
+ // in BSS so the call doesn't blow the caller's stack — the touch task
+ // is on a 3 KiB stack and can't carry ~1.6 KiB of locals here. Single
+ // caller protected by display ownership, so static is fine.
+ static char vp_name[64];
+ static char scrypt[256];
+ static char lines[INFO_MAX_LINES][INFO_LINE_BYTES];
+ bool configured;
+ viewport_run_state_t state;
+ viewport_orientation_t orient;
+ uint8_t bright;
+ uint32_t idle_ms;
+ uint64_t uptime_ms;
+ uint64_t frames, decode_err, post_err;
viewport_state_lock();
viewport_state_t *st = viewport_state_get();
-
- if (st->viewport_name[0]) {
- snprintf(line_name, sizeof(line_name), "%s", st->viewport_name);
- snprintf(line_host, sizeof(line_host), "viewport-%s.local", st->viewport_name);
- } else {
- snprintf(line_name, sizeof(line_name), "viewport");
- snprintf(line_host, sizeof(line_host), "viewport.local");
- }
- switch (st->state) {
- case VIEWPORT_STATE_AWAKE: snprintf(line_state, sizeof(line_state), "awake"); break;
- case VIEWPORT_STATE_ASLEEP: snprintf(line_state, sizeof(line_state), "asleep"); break;
- default: snprintf(line_state, sizeof(line_state), "unconfigured"); break;
- }
+ strncpy(vp_name, st->viewport_name, sizeof(vp_name));
+ strncpy(scrypt, st->scrypted_url, sizeof(scrypt));
+ vp_name[sizeof(vp_name) - 1] = '\0';
+ scrypt[sizeof(scrypt) - 1] = '\0';
+ configured = st->configured;
+ state = st->state;
+ orient = st->orientation;
+ bright = st->brightness;
+ idle_ms = st->idle_timeout_ms;
+ uptime_ms = ((uint64_t)esp_timer_get_time() - st->boot_us) / 1000;
+ frames = st->frames_received;
+ decode_err = st->decode_errors;
+ post_err = st->state_post_failures;
viewport_state_unlock();
const char *ip = net_eth_get_ip_str();
- snprintf(line_ip, sizeof(line_ip), "%s", (ip && ip[0]) ? ip : "no network");
-
- const char *lines[] = { line_name, line_host, line_ip, line_state };
- const int n_lines = (int)(sizeof(lines) / sizeof(lines[0]));
+ uint32_t free_heap = heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
+ uint32_t free_psram = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
+
+ // ----- build label/value lines as "label value", left-aligned -----
+ int n_lines = 0;
+
+ // Capped at 32 chars so it doesn't blow past INFO_LINE_BYTES once the
+ // 8-char label is prefixed. Long names get visibly truncated; ok for
+ // an info dump on a 480-wide screen.
+ char host[32];
+ if (vp_name[0]) snprintf(host, sizeof(host), "viewport-%.15s.local", vp_name);
+ else snprintf(host, sizeof(host), "viewport.local");
+
+ char scrypt_short[32];
+ if (!scrypt[0]) {
+ snprintf(scrypt_short, sizeof(scrypt_short), "none");
+ } else {
+ // Skip "http://" prefix to recover ~7 chars, then truncate.
+ const char *s = scrypt;
+ if (strncmp(s, "http://", 7) == 0) s += 7;
+ size_t len = strlen(s);
+ if (len > sizeof(scrypt_short) - 4) {
+ snprintf(scrypt_short, sizeof(scrypt_short), "%.*s...",
+ (int)(sizeof(scrypt_short) - 5), s);
+ } else {
+ // Cap with precision so GCC can prove the snprintf bound.
+ snprintf(scrypt_short, sizeof(scrypt_short), "%.*s",
+ (int)(sizeof(scrypt_short) - 1), s);
+ }
+ }
+ char idle_str[16];
+ if (idle_ms == 0) snprintf(idle_str, sizeof(idle_str), "off");
+ else snprintf(idle_str, sizeof(idle_str), "%us",
+ (unsigned)(idle_ms / 1000));
+
+ char up_str[16]; fmt_uptime(up_str, sizeof(up_str), uptime_ms);
+ char heap_str[12]; fmt_bytes(heap_str, sizeof(heap_str), free_heap);
+ char psram_str[12]; fmt_bytes(psram_str, sizeof(psram_str), free_psram);
+
+ const char *state_str =
+ (state == VIEWPORT_STATE_AWAKE) ? "awake"
+ : (state == VIEWPORT_STATE_ASLEEP) ? "asleep"
+ : "unconfigured";
+
+ // Label width is fixed at 8 chars (trailing spaces pad it). Values are
+ // left-aligned at column 8. Auto-scaler then picks a font scale to fit.
+ #define ADD(fmt, ...) do { \
+ if (n_lines < INFO_MAX_LINES) \
+ snprintf(lines[n_lines++], INFO_LINE_BYTES, fmt, ##__VA_ARGS__); \
+ } while (0)
+
+ ADD("name %s", vp_name[0] ? vp_name : "unset");
+ ADD("host %s", host);
+ ADD("ip %s", (ip && ip[0]) ? ip : "no network");
+ ADD("state %s", state_str);
+ ADD("config %s", configured ? "yes" : "no");
+ ADD("scrypt %s", scrypt_short);
+ ADD("orient %s", (orient == VIEWPORT_ORIENTATION_LANDSCAPE) ? "landscape" : "portrait");
+ ADD("bright %u", (unsigned)bright);
+ ADD("idle %s", idle_str);
+ ADD("fw %s", VIEWPORT_VERSION);
+ ADD("up %s", up_str);
+ ADD("frames %llu", (unsigned long long)frames);
+ ADD("errs %llu", (unsigned long long)(decode_err + post_err));
+ ADD("heap %s", heap_str);
+ ADD("psram %s", psram_str);
+
+ #undef ADD
+
+ // Auto-scale: largest scale where the longest line fits within 90% of
+ // width and all lines + spacing fit within 90% of height.
uint16_t w, h;
effective_dims(&w, &h);
- // Pick the largest integer scale where the longest line fits within
- // 90% of the screen width and all four lines plus inter-line spacing
- // (half a line each) fit within 90% of the screen height. Falls back
- // to scale 1 if the longest line is unusually long.
size_t longest = 0;
for (int i = 0; i < n_lines; ++i) {
size_t l = strlen(lines[i]);
if (l > longest) longest = l;
}
+
int scale = 1;
for (int s = 6; s >= 1; --s) {
- int line_w = (int)longest * 8 * s;
+ int line_w = (int)longest * 8 * s;
int total_h = n_lines * 8 * s + (n_lines - 1) * 4 * s;
if (line_w <= (w * 9) / 10 && total_h <= (h * 9) / 10) {
scale = s;
@@ -246,14 +348,15 @@ esp_err_t local_screens_show_ip(void)
}
}
- int line_h = 8 * scale;
+ int line_h = 8 * scale;
int spacing = 4 * scale;
int total_h = n_lines * line_h + (n_lines - 1) * spacing;
- int y0 = (h - total_h) / 2;
+ int y0 = (h - total_h) / 2;
+ int x0 = (w - (int)longest * 8 * scale) / 2;
clear(BG);
for (int i = 0; i < n_lines; ++i) {
- draw_centered(w, h, y0 + i * (line_h + spacing), lines[i], scale);
+ draw_left(w, h, x0, y0 + i * (line_h + spacing), lines[i], scale);
}
return display_present_rgb565(s_fb, w, h);
}
@@ -273,4 +376,3 @@ esp_err_t local_screens_show_loading(void)
return display_present_rgb565(s_fb, w, h);
}
-
diff --git a/main/local_screens.h b/main/local_screens.h
index e2d6ca1..8f8e18b 100644
--- a/main/local_screens.h
+++ b/main/local_screens.h
@@ -9,26 +9,23 @@
// bitmap font.
esp_err_t local_screens_init(void);
-// Render the identity / "who am I" screen — four centered lines:
-// <viewport name> ("viewport" if unconfigured)
-// viewport-<name>.local (mDNS hostname)
-// <current IP> ("no network" if no DHCP lease)
-// <state> ("awake" / "asleep" / "unconfigured")
-// Font scale is auto-picked to fit the longest line within 90% of width.
-// Shown on first boot, after factory reset, and as a 15s touch-long-press overlay.
-esp_err_t local_screens_show_ip(void);
+// Render the info screen — a multi-line dump of the full GET /config and
+// GET /state output, formatted as label/value pairs. Long values (e.g. the
+// scrypted URL) are truncated to fit. Font scale is auto-picked.
+// Shown on first boot and as a 15s touch-long-press overlay
+// (`local_screens_overlay`).
+esp_err_t local_screens_show_info(void);
// Render the loading screen — centered "Loading…" — shown on every wake
// until the next /frame arrives.
esp_err_t local_screens_show_loading(void);
-// Show the identity ("who am I") screen for `duration_ms`, then drop the
-// backlight off. Triggered by a ≥1.5s touch long-press in any state.
+// Show the info screen for `duration_ms`, then drop the backlight off.
+// Triggered by a ≥1.5s touch long-press in any state.
esp_err_t local_screens_overlay(uint32_t duration_ms);
-// True while the identity overlay is currently shown (timer armed).
-// Used by the touch handler to make a second tap dismiss the overlay
-// instead of re-arming it.
+// True while the info overlay is currently shown (timer armed). Used by
+// the touch handler to make a tap dismiss the overlay instead of toggling.
bool local_screens_overlay_active(void);
// Dismiss the overlay now: cancel the timer and drop the backlight.
diff --git a/main/state_machine.c b/main/state_machine.c
index c9e4367..3d95308 100644
--- a/main/state_machine.c
+++ b/main/state_machine.c
@@ -63,9 +63,9 @@ esp_err_t state_machine_set(viewport_run_state_t target)
display_wake();
// Content choice: configured devices show "Loading…" until
// Scrypted pushes a /frame; unconfigured devices show the
- // identity screen since there's no Scrypted to push anything.
+ // info screen since there's no Scrypted to push anything.
if (configured) local_screens_show_loading();
- else local_screens_show_ip();
+ else local_screens_show_info();
}
arm_idle_timer_unlocked();
ESP_LOGI(TAG, "AWAKE (%s)", configured ? "configured" : "unconfigured");
diff --git a/main/touch.c b/main/touch.c
index b3ba42c..7cde4bf 100644
--- a/main/touch.c
+++ b/main/touch.c
@@ -25,7 +25,7 @@ static const char *TAG = "touch";
#define POLL_PERIOD_MS 30
#define TAP_MAX_MS 500 // < this on release = short tap (toggle)
-#define LONG_PRESS_MS 1500 // ≥ this while held = identity overlay
+#define LONG_PRESS_MS 1500 // ≥ this while held = info overlay
#define OVERLAY_MS 15000
#define TAP_DEBOUNCE_MS 150
@@ -50,7 +50,7 @@ static void on_short_tap(void)
static void on_long_press(void)
{
- ESP_LOGI(TAG, "long-press → identity overlay for %dms", OVERLAY_MS);
+ ESP_LOGI(TAG, "long-press → info overlay for %dms", OVERLAY_MS);
if (display_is_up()) local_screens_overlay(OVERLAY_MS);
}
@@ -119,7 +119,7 @@ esp_err_t touch_init(void)
return err;
}
ESP_LOGI(TAG, "FT5426 ack'd (dev_mode=0x%02x); "
- "tap=toggle wake/sleep, %dms hold=identity overlay",
+ "tap=toggle wake/sleep, %dms hold=info overlay",
dev_mode, LONG_PRESS_MS);
BaseType_t ok = xTaskCreate(touch_task, "touch", 3072, NULL, 4, &s_task);