Redesign setup screen: title + two-step layout with a config QR

Adds a "2. CONFIGURATION" step alongside WiFi setup: a second QR code
linking straight to the captive portal page (http://<ap-ip>/), for anyone
who's joined the AP but wants a one-scan shortcut to the config form
instead of relying on the captive-portal popup. The AP netif is now
created (but not started) before the display renders, since the AP's IP
is fixed at netif creation and needed for this QR before the network is
actually up.

Pulls the pixel/text drawing primitives (previously private to
qr_onboarding.c) out into epd_draw.c/.h so the upcoming status screen can
reuse them instead of duplicating.
This commit is contained in:
2026-07-18 14:07:56 -04:00
parent 1b9226326a
commit ecc42f23c0
5 changed files with 190 additions and 80 deletions
+88
View File
@@ -0,0 +1,88 @@
#include <ctype.h>
#include <stdlib.h>
#include <string.h>
#include "epd_draw.h"
void epd_draw_pixel(uint8_t *frame, int x, int y, epd_color_t color)
{
if (x < 0 || x >= EPD_WIDTH || y < 0 || y >= EPD_HEIGHT) {
return;
}
size_t byte_index = (size_t)y * EPD_BYTES_PER_ROW + (size_t)x / 2;
uint8_t nibble = (uint8_t)color & 0x0F;
if (x % 2 == 0) {
frame[byte_index] = (frame[byte_index] & 0x0F) | (nibble << 4);
} else {
frame[byte_index] = (frame[byte_index] & 0xF0) | nibble;
}
}
/* fonts.h's sFONT tables cover the printable ASCII range starting at ' '
* (0x20), one glyph per character, MSB-first within each row (see
* components/epaper_fonts). */
static bool font_pixel_set(const sFONT *font, char c, int row, int col)
{
if (c < ' ' || (unsigned char)c > 0x7E) {
return false;
}
int bytes_per_row = (font->Width + 7) / 8;
size_t glyph_index = (size_t)(c - ' ');
const uint8_t *glyph = font->table + glyph_index * font->Height * bytes_per_row + (size_t)row * bytes_per_row;
int byte_idx = col / 8;
int bit_in_byte = 7 - (col % 8);
return (glyph[byte_idx] >> bit_in_byte) & 0x1;
}
void epd_draw_text(uint8_t *frame, const sFONT *font, const char *text, int origin_x, int origin_y)
{
int cursor_x = origin_x;
for (const char *p = text; *p != '\0'; p++) {
char c = (char)toupper((unsigned char)*p);
for (int row = 0; row < font->Height; row++) {
for (int col = 0; col < font->Width; col++) {
if (font_pixel_set(font, c, row, col)) {
epd_draw_pixel(frame, cursor_x + col, origin_y + row, EPD_COLOR_BLACK);
}
}
}
cursor_x += font->Width;
}
}
void epd_draw_text_centered(uint8_t *frame, const sFONT *font, const char *text, int center_x, int y)
{
int width = (int)strlen(text) * font->Width;
epd_draw_text(frame, font, text, center_x - width / 2, y);
}
void epd_draw_line(uint8_t *frame, int x0, int y0, int x1, int y1, int thickness)
{
int dx = x1 - x0;
int dy = y1 - y0;
int steps = abs(dx) > abs(dy) ? abs(dx) : abs(dy);
if (steps == 0) {
steps = 1;
}
int half = thickness / 2;
for (int i = 0; i <= steps; i++) {
int x = x0 + dx * i / steps;
int y = y0 + dy * i / steps;
for (int oy = -half; oy <= half; oy++) {
for (int ox = -half; ox <= half; ox++) {
epd_draw_pixel(frame, x + ox, y + oy, EPD_COLOR_BLACK);
}
}
}
}
void epd_draw_checkmark(uint8_t *frame, int x, int y, int size)
{
int thickness = size / 6;
if (thickness < 2) {
thickness = 2;
}
/* Short down-stroke, then long up-stroke -- classic checkmark proportions. */
epd_draw_line(frame, x, y + size / 2, x + size / 3, y + size - thickness, thickness);
epd_draw_line(frame, x + size / 3, y + size - thickness, x + size, y, thickness);
}
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include "epd7in3e.h"
#include "fonts.h"
/** Sets one pixel in a malloc'd EPD_FRAME_BYTES buffer (packed 2px/byte, per epd7in3e.h). */
void epd_draw_pixel(uint8_t *frame, int x, int y, epd_color_t color);
/**
* Draws text (uppercased) in the given sFONT (see components/epaper_fonts)
* with its top-left corner at (origin_x, origin_y).
*/
void epd_draw_text(uint8_t *frame, const sFONT *font, const char *text, int origin_x, int origin_y);
/** Same as epd_draw_text(), but horizontally centered on center_x. */
void epd_draw_text_centered(uint8_t *frame, const sFONT *font, const char *text, int center_x, int y);
/** Draws a line of the given thickness (in pixels) between two points. */
void epd_draw_line(uint8_t *frame, int x0, int y0, int x1, int y1, int thickness);
/** Draws a checkmark (short down-stroke, long up-stroke) inside a size x size box at (x, y). */
void epd_draw_checkmark(uint8_t *frame, int x, int y, int size);
+54 -69
View File
@@ -1,4 +1,3 @@
#include <ctype.h>
#include <stdlib.h>
#include <string.h>
@@ -6,6 +5,7 @@
#include "esp_log.h"
#include "epd7in3e.h"
#include "epd_draw.h"
#include "fonts.h"
#include "qrcodegen.h"
@@ -16,22 +16,10 @@ static const char *TAG = "qr_onboarding";
#define QR_MAX_VERSION 10
#define QR_BUFFER_LEN qrcodegen_BUFFER_LEN_FOR_VERSION(QR_MAX_VERSION)
#define QR_MODULE_PX 8
#define TEXT_MARGIN_X 40
#define TEXT_LINE_GAP 16
static void set_pixel(uint8_t *frame, int x, int y, epd_color_t color)
{
if (x < 0 || x >= EPD_WIDTH || y < 0 || y >= EPD_HEIGHT) {
return;
}
size_t byte_index = (size_t)y * EPD_BYTES_PER_ROW + (size_t)x / 2;
uint8_t nibble = (uint8_t)color & 0x0F;
if (x % 2 == 0) {
frame[byte_index] = (frame[byte_index] & 0x0F) | (nibble << 4);
} else {
frame[byte_index] = (frame[byte_index] & 0xF0) | nibble;
}
}
#define TITLE_Y 12
#define COLUMN_TOP_Y 56
#define CAPTION_GAP 16
#define LINE_GAP 8
static void draw_qr(uint8_t *frame, const uint8_t *qrcode, int origin_x, int origin_y)
{
@@ -41,58 +29,47 @@ static void draw_qr(uint8_t *frame, const uint8_t *qrcode, int origin_x, int ori
epd_color_t color = qrcodegen_getModule(qrcode, x, y) ? EPD_COLOR_BLACK : EPD_COLOR_WHITE;
for (int dy = 0; dy < QR_MODULE_PX; dy++) {
for (int dx = 0; dx < QR_MODULE_PX; dx++) {
set_pixel(frame, origin_x + x * QR_MODULE_PX + dx, origin_y + y * QR_MODULE_PX + dy, color);
epd_draw_pixel(frame, origin_x + x * QR_MODULE_PX + dx, origin_y + y * QR_MODULE_PX + dy, color);
}
}
}
}
}
/* fonts.h's sFONT tables cover the printable ASCII range starting at ' '
* (0x20), one glyph per character, MSB-first within each row (see
* components/epaper_fonts). */
static bool font_pixel_set(const sFONT *font, char c, int row, int col)
/* Encodes payload as a QR code and draws one "step" of the setup screen:
* a centered heading, the QR itself, and zero or more centered caption
* lines underneath (e.g. SSID/password as plaintext). */
static esp_err_t draw_qr_step(uint8_t *frame, int center_x, int top_y, const char *heading, const char *payload,
const char *const *captions, int caption_count)
{
if (c < ' ' || (unsigned char)c > 0x7E) {
return false;
}
int bytes_per_row = (font->Width + 7) / 8;
size_t glyph_index = (size_t)(c - ' ');
const uint8_t *glyph = font->table + glyph_index * font->Height * bytes_per_row + (size_t)row * bytes_per_row;
int byte_idx = col / 8;
int bit_in_byte = 7 - (col % 8);
return (glyph[byte_idx] >> bit_in_byte) & 0x1;
}
static int draw_text(uint8_t *frame, const sFONT *font, const char *text, int origin_x, int origin_y)
{
int cursor_x = origin_x;
for (const char *p = text; *p != '\0'; p++) {
char c = (char)toupper((unsigned char)*p);
for (int row = 0; row < font->Height; row++) {
for (int col = 0; col < font->Width; col++) {
if (font_pixel_set(font, c, row, col)) {
set_pixel(frame, cursor_x + col, origin_y + row, EPD_COLOR_BLACK);
}
}
}
cursor_x += font->Width;
}
return cursor_x;
}
esp_err_t qr_onboarding_show(const char *ssid, const char *password)
{
char payload[96];
int written = snprintf(payload, sizeof(payload), "WIFI:T:WPA;S:%s;P:%s;;", ssid, password);
ESP_RETURN_ON_FALSE(written > 0 && (size_t)written < sizeof(payload), ESP_ERR_INVALID_SIZE, TAG,
"QR payload too long");
epd_draw_text_centered(frame, &Font24, heading, center_x, top_y);
int y = top_y + Font24.Height + CAPTION_GAP;
uint8_t temp_buffer[QR_BUFFER_LEN];
uint8_t qrcode[QR_BUFFER_LEN];
bool ok = qrcodegen_encodeText(payload, temp_buffer, qrcode, qrcodegen_Ecc_MEDIUM,
qrcodegen_VERSION_MIN, QR_MAX_VERSION, qrcodegen_Mask_AUTO, true);
ESP_RETURN_ON_FALSE(ok, ESP_FAIL, TAG, "QR encoding failed (payload too long for max version)");
bool ok = qrcodegen_encodeText(payload, temp_buffer, qrcode, qrcodegen_Ecc_MEDIUM, qrcodegen_VERSION_MIN,
QR_MAX_VERSION, qrcodegen_Mask_AUTO, true);
ESP_RETURN_ON_FALSE(ok, ESP_FAIL, TAG, "QR encoding failed for '%s' (too long for max version)", heading);
int qr_size = qrcodegen_getSize(qrcode);
int qr_px = qr_size * QR_MODULE_PX;
draw_qr(frame, qrcode, center_x - qr_px / 2, y);
y += qr_px + CAPTION_GAP;
for (int i = 0; i < caption_count; i++) {
epd_draw_text_centered(frame, &Font24, captions[i], center_x, y);
y += Font24.Height + LINE_GAP;
}
return ESP_OK;
}
esp_err_t qr_onboarding_show(const char *ssid, const char *password, const char *config_url)
{
char wifi_payload[96];
int written = snprintf(wifi_payload, sizeof(wifi_payload), "WIFI:T:WPA;S:%s;P:%s;;", ssid, password);
ESP_RETURN_ON_FALSE(written > 0 && (size_t)written < sizeof(wifi_payload), ESP_ERR_INVALID_SIZE, TAG,
"WiFi QR payload too long");
/* Allocated on demand rather than statically reserved: this runs once,
* before WiFi/HTTP buffers exist, so the heap has plenty of headroom,
@@ -101,23 +78,31 @@ esp_err_t qr_onboarding_show(const char *ssid, const char *password)
ESP_RETURN_ON_FALSE(frame != NULL, ESP_ERR_NO_MEM, TAG, "Failed to allocate frame buffer");
memset(frame, (EPD_COLOR_WHITE << 4) | EPD_COLOR_WHITE, EPD_FRAME_BYTES);
int qr_size = qrcodegen_getSize(qrcode);
int qr_px = qr_size * QR_MODULE_PX;
int qr_x = (EPD_WIDTH - qr_px) / 2;
int qr_y = 24;
draw_qr(frame, qrcode, qr_x, qr_y);
epd_draw_text_centered(frame, &Font24, "ESPRESSO FRAME SETUP", EPD_WIDTH / 2, TITLE_Y);
int text_y = qr_y + qr_px + 32;
/* Divider between the two steps. */
epd_draw_line(frame, EPD_WIDTH / 2, TITLE_Y + Font24.Height + 14, EPD_WIDTH / 2, EPD_HEIGHT - 10, 1);
char ssid_line[64];
snprintf(ssid_line, sizeof(ssid_line), "SSID: %s", ssid);
draw_text(frame, &Font24, ssid_line, TEXT_MARGIN_X, text_y);
char pass_line[64];
snprintf(pass_line, sizeof(pass_line), "PASSWORD: %s", password);
draw_text(frame, &Font24, pass_line, TEXT_MARGIN_X, text_y + Font24.Height + TEXT_LINE_GAP);
const char *wifi_captions[] = { ssid_line, pass_line };
esp_err_t err = epd_display_buffer(frame, EPD_FRAME_BYTES);
esp_err_t err = draw_qr_step(frame, EPD_WIDTH / 4, COLUMN_TOP_Y, "1. WIFI SETUP", wifi_payload, wifi_captions, 2);
if (err != ESP_OK) {
free(frame);
return err;
}
const char *config_captions[] = { config_url };
err = draw_qr_step(frame, EPD_WIDTH * 3 / 4, COLUMN_TOP_Y, "2. CONFIGURATION", config_url, config_captions, 1);
if (err != ESP_OK) {
free(frame);
return err;
}
err = epd_display_buffer(frame, EPD_FRAME_BYTES);
free(frame);
return err;
+7 -6
View File
@@ -3,10 +3,11 @@
#include "esp_err.h"
/**
* Renders a WiFi-join QR code for the given AP SSID/password onto the
* e-ink panel, with the password (and SSID) also printed as plaintext
* underneath for anyone provisioning from a device that can't scan a QR
* code. Call after epd_init() and before the softAP goes up, so the join
* instructions are already on-screen by the time the network is joinable.
* Renders the two-step setup screen onto the e-ink panel: a WiFi-join QR
* code (with SSID/password also printed as plaintext, for anyone
* provisioning from a device that can't scan a QR code) and a second QR
* code linking to the captive portal config page at config_url. Call after
* epd_init() and before the softAP goes up, so the instructions are
* already on-screen by the time the network is joinable.
*/
esp_err_t qr_onboarding_show(const char *ssid, const char *password);
esp_err_t qr_onboarding_show(const char *ssid, const char *password, const char *config_url);
+16 -5
View File
@@ -374,12 +374,24 @@ void wifi_provisioning_start(void)
ESP_LOGI(TAG, "Provisioning AP: SSID='%s' password='%s'", ap_ssid, ap_password);
/* Bring up the display and show the join QR/password before the AP
* goes up, so the instructions are already on-screen by the time the
* network is joinable. */
/* Create (but don't yet start) the AP netif so its IP is known for the
* config QR before the network is actually joinable -- the default AP
* IP is fixed at netif creation, not at esp_wifi_start(). */
esp_netif_create_default_wifi_ap();
esp_netif_ip_info_t ap_ip_info;
esp_netif_get_ip_info(esp_netif_get_handle_from_ifkey("WIFI_AP_DEF"), &ap_ip_info);
char ap_ip_addr[16];
inet_ntoa_r(ap_ip_info.ip.addr, ap_ip_addr, sizeof(ap_ip_addr));
char config_url[32];
snprintf(config_url, sizeof(config_url), "http://%s/", ap_ip_addr);
/* Bring up the display and show the join QR/password + config QR
* before the AP goes up, so the instructions are already on-screen by
* the time the network is joinable. */
esp_err_t epd_err = epd_init();
if (epd_err == ESP_OK) {
esp_err_t qr_err = qr_onboarding_show(ap_ssid, ap_password);
esp_err_t qr_err = qr_onboarding_show(ap_ssid, ap_password, config_url);
if (qr_err != ESP_OK) {
ESP_LOGW(TAG, "QR onboarding render failed (%s)", esp_err_to_name(qr_err));
}
@@ -387,7 +399,6 @@ void wifi_provisioning_start(void)
ESP_LOGW(TAG, "EPD init failed (%s), continuing without display", esp_err_to_name(epd_err));
}
esp_netif_create_default_wifi_ap();
wifi_init_softap(ap_ssid, ap_password);
#ifdef CONFIG_ESP_ENABLE_DHCP_CAPTIVEPORTAL