diff --git a/firmware/main/epd_draw.c b/firmware/main/epd_draw.c new file mode 100644 index 0000000..4b9dee3 --- /dev/null +++ b/firmware/main/epd_draw.c @@ -0,0 +1,88 @@ +#include +#include +#include + +#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); +} diff --git a/firmware/main/epd_draw.h b/firmware/main/epd_draw.h new file mode 100644 index 0000000..4ba09a8 --- /dev/null +++ b/firmware/main/epd_draw.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +#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); diff --git a/firmware/main/qr_onboarding.c b/firmware/main/qr_onboarding.c index f2bb978..dd2b1f7 100644 --- a/firmware/main/qr_onboarding.c +++ b/firmware/main/qr_onboarding.c @@ -1,4 +1,3 @@ -#include #include #include @@ -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; diff --git a/firmware/main/qr_onboarding.h b/firmware/main/qr_onboarding.h index 6648064..1b23db5 100644 --- a/firmware/main/qr_onboarding.h +++ b/firmware/main/qr_onboarding.h @@ -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); diff --git a/firmware/main/wifi_provisioning.c b/firmware/main/wifi_provisioning.c index 61c4d7a..11fee51 100644 --- a/firmware/main/wifi_provisioning.c +++ b/firmware/main/wifi_provisioning.c @@ -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