Files
Nic Boet 7df116b4f1 Fix case-sensitive filesystem build failures (Linux) for ESP32 LilyGo variants
The repo has apparently only ever been built on case-insensitive
filesystems (macOS/Windows): every #include in the codebase uses
intended PascalCase/CamelCase header names (e.g. "SettingsScreen.h",
"WiFiMQTT.h"), but 28 of the actual files on disk were saved with
inconsistent casing (e.g. "Settingsscreen.h", "wifimqtt.h"). On a
case-sensitive filesystem (Linux) this is a hard compile failure, not
a cosmetic mismatch -- confirmed by running `pio run -e meck_audio_ble`
on Gentoo Linux, which failed immediately on "target.h: No such file
or directory" and a cascade of similar errors as each fix exposed the
next one.

Root causes, two flavors of the same underlying bug:

1. Header filename casing (29 files renamed via `git mv` to preserve
   history): examples/companion_radio/ui-new/*, examples/simple_repeater/*,
   and two variant-local headers (PCF85063Clock.h, TCA8418Keyboard.h x2).
   Verified safe before renaming: every file has exactly one consistent
   intended casing across all the places that #include it (checked via
   a repo-wide scan comparing every #include against on-disk filenames,
   zero conflicts found), so each rename is a pure no-op for behavior.

2. PlatformIO config paths using the wrong case for variant directories
   that are actually lowercase on disk (variants/lilygo_tdeck_pro,
   variants/lilygo_t5s3_epaper_pro):
   - `-I variants/LilyGo_TDeck_Pro` / `-I variants/LilyGo_T5S3_EPaper_Pro`
     in build_flags (3 occurrences, including lilygo_tdeck_max's
     reference to TDeck Pro's shared headers) -- broke header resolution
     for target.h and friends.
   - `+<../variants/LilyGo_TDeck_Pro>` / `+<../variants/LilyGo_T5S3_EPaper_Pro>`
     in build_src_filter (2 occurrences) -- silently excluded the board-init
     .cpp files (TDeckBoard.cpp etc.) from compilation entirely, which
     didn't fail until the *link* stage ("undefined reference to
     radio_init()", `TDeckBoard::begin()`, etc.) since PlatformIO's glob
     just matched nothing rather than erroring.

Verified fix: `pio run -e meck_audio_ble` now compiles, links, and
produces a firmware image cleanly (RAM 53.1%, Flash 49.6%).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 14:44:46 -05:00

5446 lines
182 KiB
C++

#pragma once
// =============================================================================
// WebReaderScreen.h - Minimal Web Reader ("Reader Mode") for T-Deck Pro
//
// A Lynx-like web page reader that fetches URLs over WiFi, strips HTML to
// readable text, extracts links as numbered references, and paginates
// content for the e-ink display with keyboard navigation.
//
// Requires WiFi capability - wrap includes with appropriate guards.
// Shortcut key: B (Browser) from home screen.
//
// Network backends:
// - WiFi (default): Uses ESP32 WiFi STA mode. Credentials saved to SD.
// - 4G/PPP (future): When PPP is established via A7682E modem, the same
// HTTPClient code works transparently over cellular. To enable this,
// establish PPP before calling fetchPage() and the ESP networking
// stack will route through the modem automatically.
//
// Modes:
// WIFI_SETUP - Connect to a WiFi network (scan + password entry)
// HOME - URL bar, bookmarks, history
// FETCHING - Loading indicator while downloading
// READING - Paginated text view with numbered [links]
// LINK_SELECT - Pick a link by number to follow
// =============================================================================
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include "variant.h"
#include <WiFi.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <mbedtls/platform.h>
#include <esp_heap_caps.h>
#include <SD.h>
#include <vector>
#ifdef HAS_4G_MODEM
#include "ModemManager.h"
#endif
#include "Utf8CP437.h"
#include "../NodePrefs.h"
// Forward declarations
class UITask;
// ============================================================================
// PSRAM allocator for mbedTLS
//
// ESP32-S3 internal RAM has only ~30KB largest contiguous block after WiFi
// init, but TLS handshake needs ~32-48KB for I/O buffers. Redirect mbedtls
// allocations to PSRAM (which has plenty of contiguous space) so HTTPS works.
// ============================================================================
static void* _webreader_psram_calloc(size_t num, size_t size) {
void* ptr = heap_caps_calloc(num, size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!ptr) ptr = calloc(num, size); // Fallback to internal if PSRAM fails
return ptr;
}
static void _webreader_psram_free(void* ptr) {
free(ptr); // Works for both PSRAM and internal allocations
}
static bool _webreader_tls_psram_set = false;
static void ensureTlsUsesPsram() {
#if defined(MBEDTLS_PLATFORM_MEMORY) || defined(CONFIG_MBEDTLS_PLATFORM_MEMORY)
if (!_webreader_tls_psram_set) {
mbedtls_platform_set_calloc_free(_webreader_psram_calloc, _webreader_psram_free);
_webreader_tls_psram_set = true;
Serial.println("WebReader: mbedTLS allocator redirected to PSRAM");
}
#else
if (!_webreader_tls_psram_set) {
Serial.println("WebReader: WARNING - mbedTLS PSRAM redirect not available");
_webreader_tls_psram_set = true;
}
#endif
}
// ============================================================================
// Configuration
// ============================================================================
#define WEB_CACHE_DIR "/web"
#define WEB_BOOKMARKS_FILE "/web/bookmarks.txt"
#define WEB_HISTORY_FILE "/web/history.txt"
// IRC configuration
#define IRC_CONFIG_FILE "/web/irc.cfg"
#define IRC_MAX_MESSAGES 64 // Circular buffer size
#define IRC_MAX_MSG_LEN 200 // Max display length per message
#define IRC_MAX_NICK_LEN 16
#define IRC_MAX_CHANNEL_LEN 64
#define IRC_MAX_HOST_LEN 128
#define IRC_LINE_BUF_SIZE 512 // Raw IRC protocol line buffer
#define IRC_COMPOSE_MAX 180 // Max compose message length
#define IRC_RECONNECT_MS 10000 // Auto-reconnect delay
#define IRC_PING_TIMEOUT_MS 300000 // 5 min no data = connection dead
struct IRCMessage {
char nick[IRC_MAX_NICK_LEN];
char text[IRC_MAX_MSG_LEN];
bool isSystem; // true for join/part/server notices
};
#define WEB_MAX_PAGE_SIZE 196608 // Max HTML download size (192KB)
#define WEB_MAX_TEXT_SIZE 98304 // Max extracted text size (96KB)
#define WEB_MAX_LINKS 512 // Max links per page
#define WEB_MAX_URL_LEN 256
#define WEB_MAX_BOOKMARKS 20
#define WEB_MAX_HISTORY 30
#define WEB_MAX_SSIDS 10
#define WEB_WIFI_PASS_LEN 64
#define WEB_USER_AGENT "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
// ============================================================================
// Link structure - stores extracted hyperlinks
// ============================================================================
struct WebLink {
char url[WEB_MAX_URL_LEN];
char text[48]; // Display text for the link (truncated)
};
// ============================================================================
// Form structures - stores parsed HTML forms for user interaction
// ============================================================================
#define WEB_MAX_FORMS 4
#define WEB_MAX_FORM_FIELDS 16
#define WEB_MAX_FIELD_VALUE 128
struct WebFormField {
char name[64]; // name= attribute
char value[WEB_MAX_FIELD_VALUE]; // Current/default value
char label[48]; // Display label (from <label> or placeholder)
char type; // 't'=text, 'p'=password, 'h'=hidden, 's'=submit, 'c'=checkbox
};
struct WebForm {
char action[WEB_MAX_URL_LEN]; // Form action URL
bool isPost; // true=POST, false=GET
WebFormField fields[WEB_MAX_FORM_FIELDS];
int fieldCount;
int textFieldCount; // Visible (non-hidden) field count
int formMarker; // Index in text where form marker was placed
};
// ============================================================================
// HTML Parser - minimal tag-stripping reader-mode extractor
// ============================================================================
// Tags whose content should be completely removed (not just the tag itself)
// Note: form/input/button/label are NOT skipped — they're parsed for form support.
// header is NOT skipped — it contains login/navigation links on most sites.
// nav IS skipped — its links are redundant with header and add clutter.
static const char* HTML_SKIP_TAGS[] = {
"script", "style", "nav", "footer", "aside",
"iframe", "noscript", "svg", "select", "textarea", nullptr
};
// Tags that produce a paragraph break
static const char* HTML_BLOCK_TAGS[] = {
"div", "br", "tr", "blockquote", "article", "section", "figcaption",
"ul", "ol", "dl",
nullptr
};
// Tags that get paragraph-style double breaks
static const char* HTML_PARA_TAGS[] = {
"p", nullptr
};
inline bool tagNameEquals(const char* tag, int tagLen, const char* name) {
int nameLen = strlen(name);
if (tagLen != nameLen) return false;
for (int i = 0; i < nameLen; i++) {
char c = tag[i];
if (c >= 'A' && c <= 'Z') c += 32; // tolower
if (c != name[i]) return false;
}
return true;
}
inline bool isSkipTag(const char* tag, int tagLen) {
for (int i = 0; HTML_SKIP_TAGS[i]; i++) {
if (tagNameEquals(tag, tagLen, HTML_SKIP_TAGS[i])) return true;
}
return false;
}
inline bool isBlockTag(const char* tag, int tagLen) {
for (int i = 0; HTML_BLOCK_TAGS[i]; i++) {
if (tagNameEquals(tag, tagLen, HTML_BLOCK_TAGS[i])) return true;
}
return false;
}
// Decode common HTML entities: &amp; &lt; &gt; &quot; &apos; &nbsp; &#NNN; &#xHH;
inline int decodeHtmlEntity(const char* src, int srcLen, int pos, char* outChar) {
if (pos >= srcLen || src[pos] != '&') return 0;
// Find the semicolon
int end = pos + 1;
int maxSearch = pos + 10; // entities are short
if (maxSearch > srcLen) maxSearch = srcLen;
while (end < maxSearch && src[end] != ';' && src[end] != '&' && src[end] != '<') end++;
if (end >= maxSearch || src[end] != ';') return 0;
int entLen = end - pos - 1; // length between & and ;
const char* ent = src + pos + 1;
if (entLen == 3 && memcmp(ent, "amp", 3) == 0) { *outChar = '&'; return end - pos + 1; }
if (entLen == 2 && memcmp(ent, "lt", 2) == 0) { *outChar = '<'; return end - pos + 1; }
if (entLen == 2 && memcmp(ent, "gt", 2) == 0) { *outChar = '>'; return end - pos + 1; }
if (entLen == 4 && memcmp(ent, "quot", 4) == 0) { *outChar = '"'; return end - pos + 1; }
if (entLen == 4 && memcmp(ent, "apos", 4) == 0) { *outChar = '\''; return end - pos + 1; }
if (entLen == 4 && memcmp(ent, "nbsp", 4) == 0) { *outChar = ' '; return end - pos + 1; }
if (entLen == 5 && memcmp(ent, "mdash", 5) == 0) { *outChar = '-'; return end - pos + 1; }
if (entLen == 5 && memcmp(ent, "ndash", 5) == 0) { *outChar = '-'; return end - pos + 1; }
if (entLen == 5 && memcmp(ent, "lsquo", 5) == 0) { *outChar = '\''; return end - pos + 1; }
if (entLen == 5 && memcmp(ent, "rsquo", 5) == 0) { *outChar = '\''; return end - pos + 1; }
if (entLen == 5 && memcmp(ent, "ldquo", 5) == 0) { *outChar = '"'; return end - pos + 1; }
if (entLen == 5 && memcmp(ent, "rdquo", 5) == 0) { *outChar = '"'; return end - pos + 1; }
if (entLen == 5 && memcmp(ent, "laquo", 5) == 0) { *outChar = '<'; return end - pos + 1; }
if (entLen == 5 && memcmp(ent, "raquo", 5) == 0) { *outChar = '>'; return end - pos + 1; }
if (entLen == 5 && memcmp(ent, "trade", 5) == 0) { *outChar = ' '; return end - pos + 1; }
if (entLen == 4 && memcmp(ent, "copy", 4) == 0) { *outChar = 'c'; return end - pos + 1; }
if (entLen == 4 && memcmp(ent, "bull", 4) == 0) { *outChar = '*'; return end - pos + 1; }
// hellip handled specially in caller (outputs "..." multi-char)
// Numeric: &#NNN; or &#xHH;
if (entLen >= 2 && ent[0] == '#') {
uint32_t cp = 0;
if (ent[1] == 'x' || ent[1] == 'X') {
for (int i = 2; i < entLen; i++) {
char c = ent[i];
if (c >= '0' && c <= '9') cp = cp * 16 + (c - '0');
else if (c >= 'a' && c <= 'f') cp = cp * 16 + (c - 'a' + 10);
else if (c >= 'A' && c <= 'F') cp = cp * 16 + (c - 'A' + 10);
else break;
}
} else {
for (int i = 1; i < entLen; i++) {
if (ent[i] >= '0' && ent[i] <= '9') cp = cp * 10 + (ent[i] - '0');
else break;
}
}
if (cp < 128) {
*outChar = (char)cp;
} else {
// Try CP437 mapping for common chars
uint8_t glyph = unicodeToCP437(cp);
*outChar = glyph ? (char)glyph : '?';
}
return end - pos + 1;
}
return 0; // Unknown entity
}
// Extract the tag name from inside a < > bracket.
// Returns length of tag name, and sets isClosing if it starts with /
inline int extractTagName(const char* inside, int insideLen, bool& isClosing) {
int i = 0;
isClosing = false;
while (i < insideLen && (inside[i] == ' ' || inside[i] == '\t')) i++;
if (i < insideLen && inside[i] == '/') { isClosing = true; i++; }
int start = i;
while (i < insideLen && inside[i] != ' ' && inside[i] != '/' &&
inside[i] != '>' && inside[i] != '\t' && inside[i] != '\n') i++;
return i - start; // tagName starts at inside+start, length is return value
}
// Extract href attribute value from inside an <a ...> tag
inline bool extractHref(const char* tagContent, int tagLen, char* hrefOut, int hrefMax) {
// Search for href= (case insensitive)
for (int i = 0; i < tagLen - 5; i++) {
char c0 = tagContent[i]; if (c0 >= 'A' && c0 <= 'Z') c0 += 32;
char c1 = tagContent[i+1]; if (c1 >= 'A' && c1 <= 'Z') c1 += 32;
char c2 = tagContent[i+2]; if (c2 >= 'A' && c2 <= 'Z') c2 += 32;
char c3 = tagContent[i+3]; if (c3 >= 'A' && c3 <= 'Z') c3 += 32;
if (c0 == 'h' && c1 == 'r' && c2 == 'e' && c3 == 'f') {
int j = i + 4;
while (j < tagLen && tagContent[j] == ' ') j++;
if (j < tagLen && tagContent[j] == '=') {
j++;
while (j < tagLen && tagContent[j] == ' ') j++;
char quote = 0;
if (j < tagLen && (tagContent[j] == '"' || tagContent[j] == '\'')) {
quote = tagContent[j]; j++;
}
int start = j;
if (quote) {
while (j < tagLen && tagContent[j] != quote) j++;
} else {
while (j < tagLen && tagContent[j] != ' ' && tagContent[j] != '>') j++;
}
int len = j - start;
if (len >= hrefMax) len = hrefMax - 1;
memcpy(hrefOut, tagContent + start, len);
hrefOut[len] = '\0';
return len > 0;
}
}
}
return false;
}
// Encode spaces in URL as %20 (in-place, buffer must have room)
inline void encodeUrlSpaces(char* url, int maxLen) {
int len = strlen(url);
// Count spaces to check if result fits
int spaces = 0;
for (int i = 0; i < len; i++) {
if (url[i] == ' ') spaces++;
}
int newLen = len + spaces * 2; // Each space becomes 3 chars (%20) instead of 1
if (newLen >= maxLen) return; // Won't fit, leave as-is
// Work backwards to encode in-place
url[newLen] = '\0';
int dst = newLen - 1;
for (int src = len - 1; src >= 0; src--) {
if (url[src] == ' ') {
url[dst--] = '0';
url[dst--] = '2';
url[dst--] = '%';
} else {
url[dst--] = url[src];
}
}
}
// Resolve a relative URL against a base URL
inline void resolveUrl(const char* base, const char* relative, char* out, int outMax) {
if (!relative || !relative[0]) {
strncpy(out, base, outMax - 1);
out[outMax - 1] = '\0';
return;
}
// Already absolute
if (strncmp(relative, "http://", 7) == 0 || strncmp(relative, "https://", 8) == 0) {
strncpy(out, relative, outMax - 1);
out[outMax - 1] = '\0';
return;
}
// Protocol-relative //example.com/...
if (relative[0] == '/' && relative[1] == '/') {
snprintf(out, outMax, "https:%s", relative);
return;
}
// Find scheme + host from base
const char* schemeEnd = strstr(base, "://");
if (!schemeEnd) {
strncpy(out, relative, outMax - 1);
out[outMax - 1] = '\0';
return;
}
const char* hostStart = schemeEnd + 3;
const char* pathStart = strchr(hostStart, '/');
if (relative[0] == '/') {
// Absolute path
int hostLen = pathStart ? (pathStart - base) : strlen(base);
snprintf(out, outMax, "%.*s%s", hostLen, base, relative);
} else {
// Relative path - append to base directory
if (pathStart) {
const char* lastSlash = strrchr(pathStart, '/');
int baseLen = lastSlash ? (lastSlash - base + 1) : strlen(base);
snprintf(out, outMax, "%.*s%s", baseLen, base, relative);
} else {
snprintf(out, outMax, "%s/%s", base, relative);
}
}
}
// Extract a named attribute value from inside a tag
inline bool extractAttr(const char* tag, int tagLen, const char* attrName,
char* out, int outMax) {
int nameLen = strlen(attrName);
for (int i = 0; i < tagLen - nameLen; i++) {
bool match = true;
for (int j = 0; j < nameLen && match; j++) {
char c = tag[i + j];
if (c >= 'A' && c <= 'Z') c += 32;
if (c != attrName[j]) match = false;
}
if (!match) continue;
int j = i + nameLen;
while (j < tagLen && tag[j] == ' ') j++;
if (j >= tagLen || tag[j] != '=') continue;
j++;
while (j < tagLen && tag[j] == ' ') j++;
char quote = 0;
if (j < tagLen && (tag[j] == '"' || tag[j] == '\'')) { quote = tag[j]; j++; }
int start = j;
if (quote) { while (j < tagLen && tag[j] != quote) j++; }
else { while (j < tagLen && tag[j] != ' ' && tag[j] != '>' && tag[j] != '/') j++; }
int len = j - start;
if (len >= outMax) len = outMax - 1;
memcpy(out, tag + start, len);
out[len] = '\0';
return len > 0;
}
return false;
}
// ============================================================================
// Main HTML-to-text parser
//
// Strips HTML tags, extracts text content, collects links and forms.
// Outputs clean text with paragraph breaks as double newlines.
// Links are inserted as [N] markers in the text flow.
// Forms are inserted as {FN} markers with visible fields.
// ============================================================================
struct ParseResult {
int textLen;
int linkCount;
int formCount;
};
inline ParseResult parseHtml(const char* html, int htmlLen,
char* textOut, int textMax,
WebLink* links, int maxLinks,
WebForm* forms, int maxForms,
const char* baseUrl) {
ParseResult result = {0, 0, 0};
int ti = 0; // text output index
int hi = 0; // html input index
int skipDepth = 0; // depth inside skip tags
bool inTag = false;
bool inAnchor = false;
int anchorTextStart = 0;
char currentHref[WEB_MAX_URL_LEN] = {0};
bool lastWasBreak = true; // Track if we just emitted a paragraph break (avoid doubles)
bool lastWasSpace = false;
// Form parsing state
bool inForm = false;
int currentForm = -1;
char pendingLabel[48] = {0};
bool inLabel = false;
int labelTextStart = 0;
// Find <body> tag to skip <head> section
for (int i = 0; i < htmlLen - 6; i++) {
char c = html[i];
if (c == '<') {
// Check for <body
char b1 = html[i+1]; if (b1 >= 'A' && b1 <= 'Z') b1 += 32;
char b2 = html[i+2]; if (b2 >= 'A' && b2 <= 'Z') b2 += 32;
char b3 = html[i+3]; if (b3 >= 'A' && b3 <= 'Z') b3 += 32;
char b4 = html[i+4]; if (b4 >= 'A' && b4 <= 'Z') b4 += 32;
if (b1 == 'b' && b2 == 'o' && b3 == 'd' && b4 == 'y') {
// Skip to after the >
while (i < htmlLen && html[i] != '>') i++;
hi = i + 1;
break;
}
}
}
while (hi < htmlLen && ti < textMax - 4) {
char c = html[hi];
if (c == '<') {
// Start of a tag
int tagStart = hi + 1;
int tagEnd = tagStart;
// Find closing >
while (tagEnd < htmlLen && html[tagEnd] != '>') tagEnd++;
if (tagEnd >= htmlLen) break;
int insideLen = tagEnd - tagStart;
const char* inside = html + tagStart;
// Extract tag name
bool isClosing = false;
int nameStart = 0;
while (nameStart < insideLen && (inside[nameStart] == ' ' || inside[nameStart] == '\t'))
nameStart++;
if (nameStart < insideLen && inside[nameStart] == '/') {
isClosing = true;
nameStart++;
}
int nameEnd = nameStart;
while (nameEnd < insideLen && inside[nameEnd] != ' ' && inside[nameEnd] != '/' &&
inside[nameEnd] != '>' && inside[nameEnd] != '\t' && inside[nameEnd] != '\n')
nameEnd++;
const char* tagName = inside + nameStart;
int tagNameLen = nameEnd - nameStart;
// Check for skip tags (script, style, nav, etc.)
if (isSkipTag(tagName, tagNameLen)) {
if (isClosing) {
if (skipDepth > 0) skipDepth--;
} else {
// Check if self-closing
bool selfClose = (insideLen > 0 && inside[insideLen - 1] == '/');
if (!selfClose) skipDepth++;
}
hi = tagEnd + 1;
continue;
}
if (skipDepth > 0) {
hi = tagEnd + 1;
continue;
}
// Handle paragraph tags - emit double break
bool isPara = false;
for (int pt = 0; HTML_PARA_TAGS[pt]; pt++) {
if (tagNameEquals(tagName, tagNameLen, HTML_PARA_TAGS[pt])) { isPara = true; break; }
}
if (isPara) {
if (!lastWasBreak && ti > 0) {
textOut[ti++] = '\n';
if (ti < textMax - 2) textOut[ti++] = '\n';
lastWasBreak = true;
lastWasSpace = false;
}
}
// Handle block tags - emit single break
if (!isPara && isBlockTag(tagName, tagNameLen)) {
if (!lastWasBreak && ti > 0) {
textOut[ti++] = '\n';
lastWasBreak = true;
lastWasSpace = false;
}
}
// Handle <h1>-<h6> opening: ensure line break before heading
if (!isClosing && tagNameLen == 2 && tagName[0] == 'h' &&
tagName[1] >= '1' && tagName[1] <= '6') {
if (ti < textMax - 12) {
if (!lastWasBreak && ti > 0) {
textOut[ti++] = '\n';
}
// Separator line before h4 headings (work titles on listing pages)
if (tagName[1] == '4' && ti > 1) {
const char* sep = "--------";
for (int s = 0; sep[s]; s++) textOut[ti++] = sep[s];
textOut[ti++] = '\n';
}
// Double break before h1/h2 for visual separation
if (tagName[1] <= '2' && ti > 0 && ti < textMax - 1) {
textOut[ti++] = '\n';
}
// Wrap h1-h4 headings with * markers to make them stand out
if (tagName[1] <= '4' && ti < textMax - 2) {
textOut[ti++] = '*';
textOut[ti++] = ' ';
}
lastWasBreak = false;
lastWasSpace = true;
}
}
// Handle closing </h1>-</h6>: closing marker + line break
if (isClosing && tagNameLen == 2 && tagName[0] == 'h' &&
tagName[1] >= '1' && tagName[1] <= '6') {
if (ti < textMax - 2) {
// Trim trailing space before closing marker
if (ti > 0 && textOut[ti-1] == ' ') ti--;
if (tagName[1] <= '4') {
textOut[ti++] = ' ';
textOut[ti++] = '*';
}
textOut[ti++] = '\n';
lastWasBreak = true;
lastWasSpace = false;
}
}
// Handle <a href="..."> - collect link
if (!isClosing && tagNameLen == 1 && (tagName[0] == 'a' || tagName[0] == 'A')) {
char href[WEB_MAX_URL_LEN] = {0};
if (extractHref(inside, insideLen, href, WEB_MAX_URL_LEN)) {
// Skip javascript:, mailto:, and # fragment-only links
if (strncmp(href, "javascript:", 11) != 0 &&
strncmp(href, "mailto:", 7) != 0 &&
href[0] != '#') {
resolveUrl(baseUrl, href, currentHref, WEB_MAX_URL_LEN);
inAnchor = true;
anchorTextStart = ti;
}
}
}
// Handle </a> - finalize link
if (isClosing && tagNameLen == 1 && (tagName[0] == 'a' || tagName[0] == 'A')) {
if (inAnchor && currentHref[0] && result.linkCount < maxLinks) {
WebLink& link = links[result.linkCount];
strncpy(link.url, currentHref, WEB_MAX_URL_LEN - 1);
link.url[WEB_MAX_URL_LEN - 1] = '\0';
// Extract link display text from what was accumulated
int linkTextLen = ti - anchorTextStart;
if (linkTextLen > (int)sizeof(link.text) - 1)
linkTextLen = sizeof(link.text) - 1;
if (linkTextLen > 0) {
memcpy(link.text, textOut + anchorTextStart, linkTextLen);
}
link.text[linkTextLen] = '\0';
// Append link number marker: [N]
result.linkCount++;
if (ti < textMax - 8) {
int n = result.linkCount;
textOut[ti++] = '[';
if (n >= 100) textOut[ti++] = '0' + (n / 100);
if (n >= 10) textOut[ti++] = '0' + ((n / 10) % 10);
textOut[ti++] = '0' + (n % 10);
textOut[ti++] = ']';
lastWasSpace = false;
lastWasBreak = false;
}
}
inAnchor = false;
currentHref[0] = '\0';
}
// Handle <li> - always comma-separated inline flow
if (!isClosing && tagNameLen == 2 && tagName[0] == 'l' && tagName[1] == 'i') {
if (ti < textMax - 3) {
while (ti > 0 && textOut[ti-1] == ' ') ti--;
if (ti > 0 && textOut[ti-1] != '\n' && textOut[ti-1] != ',') {
textOut[ti++] = ',';
textOut[ti++] = ' ';
lastWasSpace = true;
} else if (ti > 0 && textOut[ti-1] == ',') {
textOut[ti++] = ' ';
lastWasSpace = true;
}
lastWasBreak = (ti == 0 || textOut[ti-1] == '\n');
}
}
// Handle <dt> - inline flow with space separator (matches browser's inline stats)
if (!isClosing && tagNameLen == 2 && tagName[0] == 'd' && tagName[1] == 't') {
if (ti > 0 && !lastWasSpace && !lastWasBreak) {
textOut[ti++] = ' ';
lastWasSpace = true;
}
}
// Handle <dd> - just a space after the term (keeps "Label: Value" on one line)
if (!isClosing && tagNameLen == 2 && tagName[0] == 'd' && tagName[1] == 'd') {
if (ti > 0 && !lastWasSpace && !lastWasBreak) {
textOut[ti++] = ' ';
lastWasSpace = true;
}
lastWasBreak = false;
}
// ---- Form handling ----
// <form action="..." method="...">
if (!isClosing && tagNameLen == 4 &&
tagName[0] == 'f' && tagName[1] == 'o' && tagName[2] == 'r' && tagName[3] == 'm') {
if (result.formCount < maxForms) {
currentForm = result.formCount;
WebForm& f = forms[currentForm];
memset(&f, 0, sizeof(WebForm));
char actionBuf[WEB_MAX_URL_LEN] = {0};
extractAttr(inside, insideLen, "action", actionBuf, WEB_MAX_URL_LEN);
if (actionBuf[0]) {
resolveUrl(baseUrl, actionBuf, f.action, WEB_MAX_URL_LEN);
} else {
strncpy(f.action, baseUrl, WEB_MAX_URL_LEN - 1);
}
char methodBuf[8] = {0};
extractAttr(inside, insideLen, "method", methodBuf, sizeof(methodBuf));
// tolower
for (int m = 0; methodBuf[m]; m++) {
if (methodBuf[m] >= 'A' && methodBuf[m] <= 'Z') methodBuf[m] += 32;
}
f.isPost = (strcmp(methodBuf, "post") == 0);
inForm = true;
// Emit form marker in text
if (!lastWasBreak && ti > 0) { textOut[ti++] = '\n'; textOut[ti++] = '\n'; }
f.formMarker = ti;
if (ti < textMax - 12) {
textOut[ti++] = '-'; textOut[ti++] = '-';
textOut[ti++] = ' '; textOut[ti++] = 'F';
textOut[ti++] = 'o'; textOut[ti++] = 'r';
textOut[ti++] = 'm'; textOut[ti++] = ' ';
textOut[ti++] = '{'; textOut[ti++] = 'F';
textOut[ti++] = '0' + (result.formCount + 1);
textOut[ti++] = '}';
textOut[ti++] = ' '; textOut[ti++] = '-'; textOut[ti++] = '-';
textOut[ti++] = '\n';
}
lastWasBreak = false; lastWasSpace = false;
}
}
// </form>
if (isClosing && tagNameLen == 4 &&
tagName[0] == 'f' && tagName[1] == 'o' && tagName[2] == 'r' && tagName[3] == 'm') {
if (inForm && currentForm >= 0) {
// Emit submit hint if form has visible fields
WebForm& f = forms[currentForm];
if (f.textFieldCount > 0 && ti < textMax - 20) {
textOut[ti++] = '\n';
textOut[ti++] = '['; textOut[ti++] = 'f';
textOut[ti++] = ':'; textOut[ti++] = ' ';
textOut[ti++] = 'F'; textOut[ti++] = 'i';
textOut[ti++] = 'l'; textOut[ti++] = 'l';
textOut[ti++] = ' '; textOut[ti++] = 'f';
textOut[ti++] = 'o'; textOut[ti++] = 'r';
textOut[ti++] = 'm'; textOut[ti++] = ']';
textOut[ti++] = '\n'; textOut[ti++] = '\n';
}
result.formCount++;
lastWasBreak = true;
}
inForm = false;
currentForm = -1;
}
// <input type="..." name="..." value="...">
if (!isClosing && tagNameLen == 5 &&
tagName[0] == 'i' && tagName[1] == 'n' && tagName[2] == 'p' &&
tagName[3] == 'u' && tagName[4] == 't') {
if (inForm && currentForm >= 0) {
WebForm& f = forms[currentForm];
if (f.fieldCount < WEB_MAX_FORM_FIELDS) {
WebFormField& fld = f.fields[f.fieldCount];
memset(&fld, 0, sizeof(WebFormField));
char typeBuf[16] = "text";
extractAttr(inside, insideLen, "type", typeBuf, sizeof(typeBuf));
for (int m = 0; typeBuf[m]; m++) {
if (typeBuf[m] >= 'A' && typeBuf[m] <= 'Z') typeBuf[m] += 32;
}
extractAttr(inside, insideLen, "name", fld.name, sizeof(fld.name));
extractAttr(inside, insideLen, "value", fld.value, sizeof(fld.value));
if (strcmp(typeBuf, "hidden") == 0) {
fld.type = 'h';
// type 'h' marks it as hidden — no display needed
} else if (strcmp(typeBuf, "password") == 0) {
fld.type = 'p';
// Use pending label or placeholder
if (pendingLabel[0]) { strncpy(fld.label, pendingLabel, sizeof(fld.label)-1); pendingLabel[0] = 0; }
else extractAttr(inside, insideLen, "placeholder", fld.label, sizeof(fld.label));
if (!fld.label[0]) strncpy(fld.label, "Password", sizeof(fld.label)-1);
f.textFieldCount++;
// Emit field display
if (ti < textMax - 40) {
int w = snprintf(textOut + ti, textMax - ti, "%s: [****]\n", fld.label);
if (w > 0) ti += w;
}
lastWasBreak = false; lastWasSpace = false;
} else if (strcmp(typeBuf, "submit") == 0) {
fld.type = 's';
if (!fld.value[0]) strncpy(fld.value, "Submit", sizeof(fld.value)-1);
strncpy(fld.label, fld.value, sizeof(fld.label)-1);
} else if (strcmp(typeBuf, "checkbox") == 0) {
fld.type = 'c';
if (pendingLabel[0]) { strncpy(fld.label, pendingLabel, sizeof(fld.label)-1); pendingLabel[0] = 0; }
} else {
// text, email, search, etc — treat as text input
fld.type = 't';
if (pendingLabel[0]) { strncpy(fld.label, pendingLabel, sizeof(fld.label)-1); pendingLabel[0] = 0; }
else extractAttr(inside, insideLen, "placeholder", fld.label, sizeof(fld.label));
if (!fld.label[0]) {
// Use name as fallback label
strncpy(fld.label, fld.name, sizeof(fld.label)-1);
}
f.textFieldCount++;
// Emit field display
if (ti < textMax - 40) {
int w = snprintf(textOut + ti, textMax - ti, "%s: [___]\n", fld.label);
if (w > 0) ti += w;
}
lastWasBreak = false; lastWasSpace = false;
}
f.fieldCount++;
}
}
}
// <label> / </label> - capture text for next input
if (tagNameLen == 5 &&
tagName[0] == 'l' && tagName[1] == 'a' && tagName[2] == 'b' &&
tagName[3] == 'e' && tagName[4] == 'l') {
if (!isClosing) {
inLabel = true;
labelTextStart = ti;
} else if (inLabel) {
// Capture label text
int labelLen = ti - labelTextStart;
if (labelLen > (int)sizeof(pendingLabel) - 1)
labelLen = sizeof(pendingLabel) - 1;
if (labelLen > 0)
memcpy(pendingLabel, textOut + labelTextStart, labelLen);
pendingLabel[labelLen] = '\0';
// Remove trailing colon/space
while (labelLen > 0 && (pendingLabel[labelLen-1] == ':' || pendingLabel[labelLen-1] == ' '))
pendingLabel[--labelLen] = '\0';
// Rewind text output — label text is used for form field display only,
// not shown in reading view (avoids duplicate: label text + field marker)
ti = labelTextStart;
lastWasBreak = (ti == 0 || textOut[ti-1] == '\n');
lastWasSpace = (ti > 0 && textOut[ti-1] == ' ');
inLabel = false;
}
}
// <button> - render content inline (don't skip, as buttons may wrap links)
// Form submit buttons are handled separately in form rendering.
hi = tagEnd + 1;
continue;
}
// Skip content inside skip tags
if (skipDepth > 0) {
hi++;
continue;
}
// HTML entity
if (c == '&') {
// Special multi-char entity: &hellip; → ...
if (hi + 7 <= htmlLen && memcmp(html + hi, "&hellip;", 8) == 0) {
if (ti < textMax - 3) {
textOut[ti++] = '.';
textOut[ti++] = '.';
textOut[ti++] = '.';
lastWasSpace = false;
lastWasBreak = false;
}
hi += 8;
continue;
}
char decoded;
int consumed = decodeHtmlEntity(html, htmlLen, hi, &decoded);
if (consumed > 0) {
if (decoded == ' ') {
if (!lastWasSpace && !lastWasBreak) {
textOut[ti++] = ' ';
lastWasSpace = true;
}
} else {
textOut[ti++] = decoded;
lastWasSpace = false;
lastWasBreak = false;
}
hi += consumed;
continue;
}
}
// Whitespace collapsing
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
if (!lastWasSpace && !lastWasBreak && ti > 0) {
textOut[ti++] = ' ';
lastWasSpace = true;
}
hi++;
continue;
}
// Regular character
textOut[ti++] = c;
lastWasSpace = false;
lastWasBreak = false;
hi++;
}
textOut[ti] = '\0';
// Post-processing: clean up stray commas from empty list items
// (e.g. <li> containing only images produce ", " with no content)
int wi = 0;
for (int ri = 0; ri < ti; ri++) {
// Skip ", " that follows a newline (first empty items)
if (textOut[ri] == ',' && ri + 1 < ti && textOut[ri+1] == ' ') {
// Check if preceded by newline or start of string
if (wi == 0 || textOut[wi-1] == '\n') {
ri++; // skip the space too
continue;
}
// Check if followed by another comma (consecutive empty items)
if (ri + 2 < ti && textOut[ri+2] == ',') {
ri++; // skip ", " — next iteration handles the next comma
continue;
}
}
textOut[wi++] = textOut[ri];
}
textOut[wi] = '\0';
ti = wi;
// Also collapse multiple consecutive newlines (max 2)
wi = 0;
int nlCount = 0;
for (int ri = 0; ri < ti; ri++) {
if (textOut[ri] == '\n') {
nlCount++;
if (nlCount <= 2) textOut[wi++] = textOut[ri];
} else {
nlCount = 0;
textOut[wi++] = textOut[ri];
}
}
textOut[wi] = '\0';
ti = wi;
result.textLen = ti;
return result;
}
// ============================================================================
// Word Wrap - reuse same algorithm as TextReaderScreen
// (included from TextReaderScreen.h via findLineBreak, or duplicated here
// for standalone compilation)
// ============================================================================
#ifndef WEBREADER_WORD_WRAP
#define WEBREADER_WORD_WRAP
struct WebWrapResult {
int lineEnd;
int nextStart;
};
inline WebWrapResult webFindLineBreak(const char* buffer, int bufLen,
int lineStart, int maxChars) {
WebWrapResult result;
result.lineEnd = lineStart;
result.nextStart = lineStart;
if (lineStart >= bufLen) return result;
int charCount = 0;
int lastBreakPoint = -1;
bool inWord = false;
for (int i = lineStart; i < bufLen; i++) {
char c = buffer[i];
if (c == '\n') {
result.lineEnd = i;
result.nextStart = i + 1;
if (result.nextStart < bufLen && buffer[result.nextStart] == '\r')
result.nextStart++;
return result;
}
if (c == '\r') {
result.lineEnd = i;
result.nextStart = i + 1;
if (result.nextStart < bufLen && buffer[result.nextStart] == '\n')
result.nextStart++;
return result;
}
if (c >= 32) {
if ((uint8_t)c >= 0x80 && (uint8_t)c < 0xC0) continue;
charCount++;
if (c == ' ' || c == '\t') {
if (inWord) {
lastBreakPoint = i;
inWord = false;
}
} else if (c == '-') {
if (inWord) lastBreakPoint = i + 1;
} else {
inWord = true;
}
if (charCount >= maxChars) {
if (lastBreakPoint > lineStart) {
result.lineEnd = lastBreakPoint;
result.nextStart = lastBreakPoint;
while (result.nextStart < bufLen &&
(buffer[result.nextStart] == ' ' || buffer[result.nextStart] == '\t'))
result.nextStart++;
} else {
result.lineEnd = i;
result.nextStart = i;
}
return result;
}
}
}
result.lineEnd = bufLen;
result.nextStart = bufLen;
return result;
}
#endif
// ============================================================================
// WebReaderScreen
// ============================================================================
class WebReaderScreen : public UIScreen {
public:
enum Mode {
WIFI_SETUP, // Connect to WiFi
HOME, // URL entry + bookmarks
FETCHING, // Loading page
READING, // Viewing extracted text
LINK_SELECT, // Choosing a link number
FORM_FILL, // Filling in a form
IRC_SETUP, // IRC server/nick/channel configuration
IRC_CHAT, // Live IRC chat view
DOWNLOAD_DONE // File download completed (EPUB, etc.)
};
enum WifiState {
WIFI_IDLE,
WIFI_SCANNING,
WIFI_SCAN_DONE,
WIFI_ENTERING_PASS,
WIFI_CONNECTING,
WIFI_CONNECTED,
WIFI_FAILED
};
private:
UITask* _task;
NodePrefs* _prefs;
Mode _mode;
bool _initialized;
uint8_t _lastFontPref;
DisplayDriver* _display;
// Display layout (calculated once)
int _charsPerLine;
int _linesPerPage;
int _lineHeight;
int _footerHeight;
// WiFi state
WifiState _wifiState;
String _ssidList[WEB_MAX_SSIDS];
int _ssidCount;
int _selectedSSID;
char _wifiPass[WEB_WIFI_PASS_LEN];
int _wifiPassLen;
unsigned long _wifiTimeout;
String _connectedSSID;
// URL entry
char _urlBuffer[WEB_MAX_URL_LEN];
int _urlLen;
int _urlCursor; // Cursor position within URL
// Page content
char* _textBuffer; // PSRAM allocated - extracted text
int _textLen;
WebLink* _links; // PSRAM allocated - extracted links
int _linkCount;
char _pageTitle[64]; // Page title (from <title> tag)
char _currentUrl[WEB_MAX_URL_LEN];
std::vector<String> _backHistory; // URL stack for back navigation
// Pagination
std::vector<int> _pageOffsets; // Byte offset of each page start
int _currentPage;
int _totalPages;
// Bookmarks & History
std::vector<String> _bookmarks;
std::vector<String> _history;
int _homeSelected; // Selected item in home view (0=IRC, 1=URL, 2=Search, then bookmarks, then history)
int _homeScrollY; // Pixel scroll offset for home view
bool _urlEditing; // True when URL bar is active for text entry
bool _searchEditing; // True when search bar is active for text entry
char _searchBuffer[128]; // Search query text
int _searchLen;
// Link selection
int _linkInput; // Accumulated link number digits
bool _linkInputActive;
// Brief toast notification
char _toastMsg[32];
unsigned long _toastTime; // millis() when toast was set
// Forms
WebForm* _forms; // PSRAM allocated
int _formCount;
int _activeForm; // Which form is being filled (-1 = none)
int _activeField; // Which field in the active form (index into visible fields)
bool _formFieldEditing; // True when typing into a form field
char _formEditBuf[WEB_MAX_FIELD_VALUE]; // Edit buffer for current field
int _formEditLen;
unsigned long _formLastCharAt; // millis() of last char typed (for brief password reveal)
// Cookies (simple key=value store per domain)
#define WEB_MAX_COOKIES 16
struct Cookie {
char domain[64];
char name[64];
char value[512]; // AO3 session cookies are 300+ chars of base64
};
Cookie* _cookies; // PSRAM allocated
int _cookieCount;
// Fetch state
unsigned long _fetchStartTime;
int _fetchProgress; // Bytes received so far
int _fetchRetryCount; // Current retry attempt (0 = first try)
String _fetchError;
// Persistent TLS client — kept alive between page loads to reuse TCP
// connections to the same host (avoids repeated 5-8s TLS handshakes).
// Destroyed on error, host change, or WiFi disconnect.
WiFiClientSecure* _tlsClient;
String _tlsHost; // Host _tlsClient is connected/configured for
// Download state (for EPUB/file downloads to SD)
char _downloadedFile[64]; // Filename of last downloaded file
bool _downloadOk; // true if download succeeded
bool _requestTextReader; // true when user wants to open downloaded file in reader
// ---- IRC State ----
WiFiClient* _ircClient; // WiFiClient (plain) or WiFiClientSecure (TLS)
bool _ircUseTLS; // true when connected via TLS
bool _ircConnected;
bool _ircRegistered; // Received 001 RPL_WELCOME
bool _ircJoined; // Successfully joined channel
char _ircHost[IRC_MAX_HOST_LEN];
uint16_t _ircPort;
char _ircNick[IRC_MAX_NICK_LEN];
char _ircChannel[IRC_MAX_CHANNEL_LEN];
// Message circular buffer
IRCMessage* _ircMessages; // PSRAM allocated
int _ircMsgHead; // Next write position
int _ircMsgCount; // Total messages stored
// Protocol line buffer (partial line accumulation)
char _ircLineBuf[IRC_LINE_BUF_SIZE];
int _ircLineLen;
// Compose buffer
char _ircCompose[IRC_COMPOSE_MAX];
int _ircComposeLen;
bool _ircComposing; // true when typing a message
// Display state
int _ircScrollPos; // Scroll offset from bottom (0 = newest)
int _ircLinesPerPage;
// Setup screen state
int _ircSetupField; // 0=host, 1=port, 2=nick, 3=channel, 4=connect button
char _ircSetupBuf[IRC_MAX_HOST_LEN]; // Edit buffer for setup fields
int _ircSetupBufLen;
bool _ircSetupEditing; // true when typing into a field
// Connection management
unsigned long _ircLastDataTime;
unsigned long _ircReconnectAt; // 0 = no reconnect pending
bool _ircDirty; // New messages need rendering
unsigned long _ircLastRender; // Throttle directRedraw from poll()
// ---- Memory Management ----
bool allocateBuffers() {
if (!_textBuffer) {
_textBuffer = (char*)ps_malloc(WEB_MAX_TEXT_SIZE);
if (!_textBuffer) {
Serial.println("WebReader: Failed to allocate text buffer from PSRAM");
return false;
}
}
if (!_links) {
_links = (WebLink*)ps_malloc(sizeof(WebLink) * WEB_MAX_LINKS);
if (!_links) {
Serial.println("WebReader: Failed to allocate links buffer from PSRAM");
return false;
}
}
return true;
}
void freeBuffers() {
if (_textBuffer) { free(_textBuffer); _textBuffer = nullptr; }
if (_links) { free(_links); _links = nullptr; }
_textLen = 0;
_linkCount = 0;
_formCount = 0;
_pageOffsets.clear();
}
// ---- Domain extraction from URL ----
static void extractDomain(const char* url, char* domain, int domainMax) {
const char* host = strstr(url, "://");
if (host) host += 3; else host = url;
int i = 0;
while (host[i] && host[i] != '/' && host[i] != ':' && host[i] != '?' && host[i] != '#' && i < domainMax - 1) {
domain[i] = host[i]; i++;
}
domain[i] = '\0';
}
// ---- Cookie Management ----
void setCookie(const char* domain, const char* name, const char* value) {
// Update existing cookie
for (int i = 0; i < _cookieCount; i++) {
if (strcmp(_cookies[i].domain, domain) == 0 &&
strcmp(_cookies[i].name, name) == 0) {
strncpy(_cookies[i].value, value, sizeof(_cookies[i].value) - 1);
return;
}
}
// Add new cookie
if (_cookieCount < WEB_MAX_COOKIES) {
Cookie& c = _cookies[_cookieCount++];
strncpy(c.domain, domain, sizeof(c.domain) - 1);
strncpy(c.name, name, sizeof(c.name) - 1);
strncpy(c.value, value, sizeof(c.value) - 1);
}
}
// Build Cookie header value for a domain
String buildCookieHeader(const char* domain) {
String result;
for (int i = 0; i < _cookieCount; i++) {
// Match domain (simple suffix match)
if (strstr(domain, _cookies[i].domain) ||
strcmp(domain, _cookies[i].domain) == 0) {
if (result.length() > 0) result += "; ";
result += _cookies[i].name;
result += "=";
result += _cookies[i].value;
}
}
return result;
}
// Remove Cloudflare challenge cookies for a domain.
// Stale __cf_bm and _cfuvid tokens from failed handshakes can poison
// subsequent requests, causing Cloudflare to keep returning 525/503.
void clearCloudflareCookies(const char* domain) {
int dst = 0;
for (int i = 0; i < _cookieCount; i++) {
bool isCfDomain = (strstr(domain, _cookies[i].domain) ||
strcmp(domain, _cookies[i].domain) == 0);
bool isCfCookie = (strncmp(_cookies[i].name, "__cf_bm", 7) == 0 ||
strncmp(_cookies[i].name, "_cfuvid", 7) == 0 ||
strncmp(_cookies[i].name, "cf_", 3) == 0);
if (isCfDomain && isCfCookie) {
Serial.printf("WebReader: Cleared CF cookie '%s'\n", _cookies[i].name);
continue; // Skip — don't copy to dst
}
if (dst != i) _cookies[dst] = _cookies[i];
dst++;
}
_cookieCount = dst;
}
// Parse Set-Cookie header(s) from HTTP response
void parseSetCookie(const String& headerVal, const char* domain) {
// Format: name=value; Path=/; ...
int eq = headerVal.indexOf('=');
if (eq < 1) return;
String name = headerVal.substring(0, eq);
name.trim();
int semi = headerVal.indexOf(';', eq);
String value = (semi > 0) ? headerVal.substring(eq + 1, semi)
: headerVal.substring(eq + 1);
value.trim();
// Check for domain in cookie attributes
char cookieDomain[64];
strncpy(cookieDomain, domain, sizeof(cookieDomain) - 1);
int domIdx = headerVal.indexOf("domain=");
if (domIdx < 0) domIdx = headerVal.indexOf("Domain=");
if (domIdx >= 0) {
int dStart = domIdx + 7;
int dEnd = headerVal.indexOf(';', dStart);
String d = (dEnd > 0) ? headerVal.substring(dStart, dEnd) : headerVal.substring(dStart);
d.trim();
if (d.startsWith(".")) d = d.substring(1);
strncpy(cookieDomain, d.c_str(), sizeof(cookieDomain) - 1);
}
setCookie(cookieDomain, name.c_str(), value.c_str());
Serial.printf("Cookie SET: %s=%s (domain=%s)\n", name.c_str(), value.c_str(), cookieDomain);
}
// Capture all Set-Cookie headers from response using index iteration.
// ESP32 HTTPClient's header("Set-Cookie") may only return the first/last
// when multiple Set-Cookie headers exist. Iterating by index catches all.
void captureResponseCookies(HTTPClient& http, const char* domain) {
int hCount = http.headers();
Serial.printf("Cookie capture: %d header slots, domain=%s\n", hCount, domain);
int found = 0;
for (int h = 0; h < hCount; h++) {
String name = http.headerName(h);
String val = http.header(h);
Serial.printf(" Slot[%d] '%s' (%d chars) = '%.200s%s'\n", h, name.c_str(),
val.length(), val.c_str(), val.length() > 200 ? "..." : "");
if (name.equalsIgnoreCase("Set-Cookie")) {
// A single header entry might still contain multiple cookies
// concatenated by ESP32 with comma. Try to split them.
int start = 0;
while (start < (int)val.length()) {
int comma = val.indexOf(", ", start);
String single;
if (comma >= 0) {
// Check if what follows looks like a new cookie (name=value before ;)
String rest = val.substring(comma + 2);
int eq = rest.indexOf('=');
int semi = rest.indexOf(';');
if (eq > 0 && (semi < 0 || eq < semi)) {
single = val.substring(start, comma);
start = comma + 2;
} else {
// Comma is part of a cookie value (e.g. date), skip
comma = val.indexOf(", ", comma + 2);
if (comma >= 0) {
single = val.substring(start, comma);
start = comma + 2;
} else {
single = val.substring(start);
start = val.length();
}
}
} else {
single = val.substring(start);
start = val.length();
}
parseSetCookie(single, domain);
found++;
}
}
}
// Also try the name-based lookup in case index iteration missed something
if (found == 0 && http.hasHeader("Set-Cookie")) {
String sc = http.header("Set-Cookie");
if (sc.length() > 0) {
Serial.printf("Cookie: fallback name-based: %.80s\n", sc.c_str());
parseSetCookie(sc, domain);
}
}
if (found == 0 && !http.hasHeader("Set-Cookie")) {
Serial.println("Cookie capture: NO Set-Cookie headers in response at all");
}
Serial.printf("Cookie jar now has %d cookies for domain %s\n", _cookieCount, domain);
}
// ---- URL Encoding ----
static void urlEncode(const char* src, char* dst, int dstMax) {
static const char hex[] = "0123456789ABCDEF";
int di = 0;
for (int i = 0; src[i] && di < dstMax - 4; i++) {
char c = src[i];
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~') {
dst[di++] = c;
} else if (c == ' ') {
dst[di++] = '+';
} else {
dst[di++] = '%';
dst[di++] = hex[(uint8_t)c >> 4];
dst[di++] = hex[(uint8_t)c & 0x0F];
}
}
dst[di] = '\0';
}
// Build form POST body from form fields
String buildFormBody(const WebForm& form) {
String body;
for (int i = 0; i < form.fieldCount; i++) {
const WebFormField& f = form.fields[i];
if (!f.name[0]) continue; // Skip unnamed fields
if (body.length() > 0) body += "&";
char encName[128], encVal[256];
urlEncode(f.name, encName, sizeof(encName));
urlEncode(f.value, encVal, sizeof(encVal));
body += encName;
body += "=";
body += encVal;
}
return body;
}
// ---- WiFi Management ----
// Note: On the BLE variant, WiFi and BLE coexist on the ESP32-S3 radio.
// Some throughput reduction is normal. WiFi is started on-demand and stays
// connected until explicitly disconnected or the device sleeps.
// On the 4G variant, if PPP is active (modem providing network), WiFi is
// not needed — HTTPClient routes through the PPP interface automatically.
bool isWiFiConnected() {
return WiFi.status() == WL_CONNECTED;
}
// Generic network check: returns true if any network interface is up.
// Works for WiFi and will also detect PPP (4G modem) connections.
bool isNetworkAvailable() {
if (WiFi.status() == WL_CONNECTED) return true;
#ifdef HAS_4G_MODEM
// When PPP is active via the A7682E modem, the ESP32 gets an IP on the
// ppp netif. Check if we have a non-zero IP on any interface.
// TODO: implement PPP status check when modem PPP driver is added
#endif
return false;
}
void startWifiScan() {
_wifiState = WIFI_SCANNING;
// Show scanning splash before the blocking scan (3-5 seconds)
if (_display) {
_display->startFrame();
_display->setColor(DisplayDriver::GREEN);
_display->setTextSize(1);
_display->setCursor(0, 0);
_display->print("WiFi Setup");
_display->drawRect(0, 11, _display->width(), 1);
_display->setColor(DisplayDriver::LIGHT);
_display->setTextSize(_prefs->smallTextSize());
_display->setCursor(0, 18);
_display->print("Scanning for networks...");
_display->endFrame();
}
Serial.printf("WebReader: Starting WiFi scan (mode=%d, status=%d)\n",
WiFi.getMode(), WiFi.status());
// Use blocking scan — takes 3-5 seconds, which is fine for e-ink.
// Async scan has race conditions with WiFi.disconnect() on ESP32-S3.
int n = WiFi.scanNetworks(false, false, false, 300); // blocking, no hidden, active, 300ms/ch
Serial.printf("WebReader: Scan complete, found %d networks\n", n);
if (n > 0) {
_ssidCount = min(n, WEB_MAX_SSIDS);
for (int i = 0; i < _ssidCount; i++) {
_ssidList[i] = WiFi.SSID(i);
Serial.printf(" [%d] %s (RSSI %d)\n", i, _ssidList[i].c_str(), WiFi.RSSI(i));
}
WiFi.scanDelete();
_selectedSSID = 0;
_wifiState = WIFI_SCAN_DONE;
} else if (n == 0) {
_wifiState = WIFI_FAILED;
_fetchError = "No networks found";
} else {
_wifiState = WIFI_FAILED;
_fetchError = "Scan failed (err " + String(n) + ")";
Serial.printf("WebReader: Scan error code: %d\n", n);
}
}
void checkWifiScan() {
int n = WiFi.scanComplete();
if (n == WIFI_SCAN_RUNNING) {
if (millis() > _wifiTimeout) {
Serial.println("WebReader: scan timeout");
_wifiState = WIFI_FAILED;
_fetchError = "Scan timeout";
}
return;
}
if (n == WIFI_SCAN_FAILED || n < 0) {
Serial.printf("WebReader: scanComplete returned %d (failed)\n", n);
_wifiState = WIFI_FAILED;
_fetchError = "Scan failed";
return;
}
if (n == 0) {
Serial.println("WebReader: scan found 0 networks");
_wifiState = WIFI_FAILED;
_fetchError = "No networks found";
return;
}
Serial.printf("WebReader: scan found %d networks\n", n);
_ssidCount = min(n, WEB_MAX_SSIDS);
for (int i = 0; i < _ssidCount; i++) {
_ssidList[i] = WiFi.SSID(i);
}
WiFi.scanDelete();
_selectedSSID = 0;
_wifiState = WIFI_SCAN_DONE;
}
void connectToSSID(const String& ssid, const char* password) {
_wifiState = WIFI_CONNECTING;
WiFi.begin(ssid.c_str(), password);
_wifiTimeout = millis() + 15000;
_connectedSSID = ssid;
}
void checkWifiConnect() {
if (WiFi.status() == WL_CONNECTED) {
_wifiState = WIFI_CONNECTED;
Serial.printf("WebReader: WiFi connected to %s, IP: %s\n",
_connectedSSID.c_str(), WiFi.localIP().toString().c_str());
// Save credentials to SD for auto-reconnect
saveWifiCredentials(_connectedSSID.c_str(), _wifiPass);
return;
}
if (millis() > _wifiTimeout) {
_wifiState = WIFI_FAILED;
_fetchError = "Connection timeout";
}
}
// Show a brief "Connected!" confirmation splash, then transition to HOME.
// Called after successful WiFi connection (auto or manual).
void showConnectedAndGoHome() {
if (_display) {
_display->startFrame();
_display->setColor(DisplayDriver::GREEN);
_display->setTextSize(1);
_display->setCursor(0, 0);
_display->print("Web Reader");
_display->drawRect(0, 11, _display->width(), 1);
_display->setTextSize(_prefs->smallTextSize());
_display->setCursor(0, 18);
_display->print("Connected!");
_display->setCursor(0, 30);
_display->setColor(DisplayDriver::LIGHT);
char ipBuf[48];
snprintf(ipBuf, sizeof(ipBuf), "SSID: %s", _connectedSSID.c_str());
_display->print(ipBuf);
_display->setCursor(0, 40);
snprintf(ipBuf, sizeof(ipBuf), "IP: %s", WiFi.localIP().toString().c_str());
_display->print(ipBuf);
_display->endFrame();
}
delay(1500); // Brief pause so user sees the confirmation
_mode = HOME;
directRedraw(); // Now show the URL entry page
}
void saveWifiCredentials(const char* ssid, const char* pass) {
if (!SD.exists(WEB_CACHE_DIR)) SD.mkdir(WEB_CACHE_DIR);
File f = SD.open("/web/wifi.cfg", FILE_WRITE);
if (f) {
f.println(ssid);
f.println(pass);
f.close();
}
digitalWrite(SDCARD_CS, HIGH);
}
bool loadAndAutoConnect() {
File f = SD.open("/web/wifi.cfg", FILE_READ);
if (!f) { digitalWrite(SDCARD_CS, HIGH); return false; }
String ssid = f.readStringUntil('\n');
String pass = f.readStringUntil('\n');
f.close();
digitalWrite(SDCARD_CS, HIGH);
ssid.trim();
pass.trim();
if (ssid.length() == 0) return false;
Serial.printf("WebReader: Auto-connecting to %s\n", ssid.c_str());
// WiFi STA mode already set by main.cpp before enter()
WiFi.begin(ssid.c_str(), pass.c_str());
// Brief blocking wait (up to 5 seconds) during init
unsigned long timeout = millis() + 5000;
while (WiFi.status() != WL_CONNECTED && millis() < timeout) {
delay(100);
}
if (WiFi.status() == WL_CONNECTED) {
_connectedSSID = ssid;
_wifiState = WIFI_CONNECTED;
Serial.printf("WebReader: Auto-connected, IP: %s\n",
WiFi.localIP().toString().c_str());
return true;
}
return false;
}
// Read HTTP response body, handling both chunked and fixed-length transfers.
// Chunked transfer encoding embeds chunk size headers in the stream:
// <hex-size>\r\n<data>\r\n ... 0\r\n\r\n
// If we read raw from getStreamPtr(), those headers corrupt our HTML.
int readResponseBody(HTTPClient& http, char* buffer, int maxLen) {
int contentLen = http.getSize();
WiFiClient* stream = http.getStreamPtr();
int totalRead = 0;
unsigned long lastSplash = millis();
if (contentLen > 0) {
// Known content length — read directly (no chunking)
int toRead = min(contentLen, maxLen - 1);
while (totalRead < toRead) {
if (!stream->available()) {
unsigned long waitStart = millis();
while (!stream->available() && (millis() - waitStart) < 5000) {
delay(10);
yield();
}
if (!stream->available()) break;
}
int chunk = stream->readBytes(buffer + totalRead,
min(1024, toRead - totalRead));
if (chunk <= 0) break;
totalRead += chunk;
_fetchProgress = totalRead;
if (_display && (millis() - lastSplash) >= 2000) {
_display->startFrame();
renderFetching(*_display);
_display->endFrame();
lastSplash = millis();
}
yield();
}
} else {
// Chunked transfer or unknown length (-1)
// Read chunk by chunk: each chunk starts with hex size + \r\n
Serial.println("WebReader: Chunked transfer encoding detected");
while (totalRead < maxLen - 1) {
// Read chunk size line (hex + \r\n)
String sizeLine = stream->readStringUntil('\n');
sizeLine.trim(); // Remove \r
if (sizeLine.length() == 0) {
// Empty line, try to continue
if (!stream->available()) break;
continue;
}
// Parse hex chunk size
int chunkSize = (int)strtol(sizeLine.c_str(), nullptr, 16);
if (chunkSize == 0) break; // Final chunk — we're done
// Read chunk data
int chunkRead = 0;
while (chunkRead < chunkSize && totalRead < maxLen - 1) {
if (!stream->available()) {
unsigned long waitStart = millis();
while (!stream->available() && (millis() - waitStart) < 5000) {
delay(10);
yield();
}
if (!stream->available()) break;
}
int toGet = min(1024, min(chunkSize - chunkRead, maxLen - 1 - totalRead));
int got = stream->readBytes(buffer + totalRead, toGet);
if (got <= 0) break;
totalRead += got;
chunkRead += got;
_fetchProgress = totalRead;
if (_display && (millis() - lastSplash) >= 2000) {
_display->startFrame();
renderFetching(*_display);
_display->endFrame();
lastSplash = millis();
}
yield();
}
// Consume trailing \r\n after chunk data
if (stream->available()) {
char cr = stream->read(); // \r
if (stream->available() && cr == '\r') stream->read(); // \n
}
}
}
return totalRead;
}
// ---- EPUB/File Download to SD ----
// Streams HTTP response body directly to SD card without buffering in RAM.
// Used for EPUB downloads from sites like AO3.
bool downloadToSD(HTTPClient& http, const char* url, const String& contentDisposition) {
// Extract filename from Content-Disposition or URL
char filename[64] = {0};
// Try Content-Disposition: attachment; filename="Title.epub"
if (contentDisposition.length() > 0) {
int fnIdx = contentDisposition.indexOf("filename=");
if (fnIdx >= 0) {
int start = fnIdx + 9;
if (contentDisposition[start] == '"') start++;
int end = start;
while (end < (int)contentDisposition.length() &&
contentDisposition[end] != '"' && contentDisposition[end] != ';')
end++;
int len = end - start;
if (len > 60) len = 60;
memcpy(filename, contentDisposition.c_str() + start, len);
filename[len] = '\0';
}
}
// Fallback: extract from URL path
if (filename[0] == '\0') {
const char* lastSlash = strrchr(url, '/');
if (lastSlash) {
const char* start = lastSlash + 1;
// Strip query params
const char* qmark = strchr(start, '?');
int len = qmark ? (qmark - start) : strlen(start);
if (len > 60) len = 60;
memcpy(filename, start, len);
filename[len] = '\0';
}
}
// Ensure .epub extension
if (filename[0] == '\0') {
snprintf(filename, sizeof(filename), "download_%lu.epub", millis());
}
// URL-decode the filename (%20 -> space, etc.)
char decoded[64];
int di = 0;
for (int i = 0; filename[i] && di < 62; i++) {
if (filename[i] == '%' && filename[i+1] && filename[i+2]) {
char hex[3] = {filename[i+1], filename[i+2], 0};
decoded[di++] = (char)strtol(hex, nullptr, 16);
i += 2;
} else {
decoded[di++] = filename[i];
}
}
decoded[di] = '\0';
strncpy(filename, decoded, sizeof(filename) - 1);
// Replace underscores with spaces for friendlier names
// (AO3 uses underscores in download filenames)
// Actually, keep as-is — filesystem is happier without spaces
Serial.printf("WebReader: Downloading to /books/%s\n", filename);
// Ensure /books/ directory exists
digitalWrite(SDCARD_CS, LOW);
if (!SD.exists("/books")) {
SD.mkdir("/books");
}
// Build full path
char filepath[128];
snprintf(filepath, sizeof(filepath), "/books/%s", filename);
// Check if file already exists
if (SD.exists(filepath)) {
Serial.printf("WebReader: File already exists: %s\n", filepath);
// Overwrite — user explicitly navigated to download
}
File outFile = SD.open(filepath, FILE_WRITE);
if (!outFile) {
digitalWrite(SDCARD_CS, HIGH);
http.end();
_fetchError = "SD write failed";
_mode = HOME;
Serial.println("WebReader: Failed to open SD file for writing");
return false;
}
// Stream from HTTP to SD in 4KB chunks (heap allocated to avoid stack overflow)
WiFiClient* stream = http.getStreamPtr();
int contentLen = http.getSize();
int totalWritten = 0;
unsigned long lastSplash = 0;
const int DL_BUF_SIZE = 4096;
uint8_t* buf = (uint8_t*)ps_malloc(DL_BUF_SIZE);
if (!buf) {
buf = (uint8_t*)malloc(DL_BUF_SIZE); // Fallback to internal
}
if (!buf) {
outFile.close();
digitalWrite(SDCARD_CS, HIGH);
http.end();
_fetchError = "Out of memory";
_mode = HOME;
return false;
}
bool writeError = false;
_fetchProgress = 0;
// Show initial download screen
if (_display) {
_display->startFrame();
_display->setColor(DisplayDriver::GREEN);
_display->setTextSize(2);
_display->setCursor(10, 10);
_display->print("Downloading");
_display->setTextSize(1);
_display->setColor(DisplayDriver::LIGHT);
_display->setCursor(10, 35);
char fnDisp[40];
strncpy(fnDisp, filename, 38);
fnDisp[38] = '\0';
_display->print(fnDisp);
_display->setCursor(10, 50);
_display->print("to /books/");
_display->endFrame();
lastSplash = millis();
}
while (true) {
if (!stream->available()) {
unsigned long waitStart = millis();
while (!stream->available() && (millis() - waitStart) < 10000) {
delay(10);
yield();
}
if (!stream->available()) break; // Timeout or done
}
int toRead = DL_BUF_SIZE;
if (contentLen > 0) {
int remaining = contentLen - totalWritten;
if (remaining <= 0) break;
if (toRead > remaining) toRead = remaining;
}
int got = stream->readBytes(buf, toRead);
if (got <= 0) break;
size_t written = outFile.write(buf, got);
if ((int)written != got) {
writeError = true;
Serial.printf("WebReader: SD write error: wrote %d of %d bytes\n",
(int)written, got);
break;
}
totalWritten += got;
_fetchProgress = totalWritten;
// Update progress display every 2 seconds
if (_display && (millis() - lastSplash) >= 2000) {
_display->startFrame();
_display->setColor(DisplayDriver::GREEN);
_display->setTextSize(2);
_display->setCursor(10, 10);
_display->print("Downloading");
_display->setTextSize(1);
_display->setColor(DisplayDriver::LIGHT);
_display->setCursor(10, 35);
char progBuf[48];
if (contentLen > 0) {
int pct = (totalWritten * 100) / contentLen;
snprintf(progBuf, sizeof(progBuf), "%d / %d KB (%d%%)",
totalWritten / 1024, contentLen / 1024, pct);
} else {
snprintf(progBuf, sizeof(progBuf), "%d KB downloaded",
totalWritten / 1024);
}
_display->print(progBuf);
_display->setCursor(10, 50);
char fnDisp2[40];
strncpy(fnDisp2, filename, 38);
fnDisp2[38] = '\0';
_display->print(fnDisp2);
_display->endFrame();
lastSplash = millis();
}
yield();
}
outFile.close();
free(buf);
digitalWrite(SDCARD_CS, HIGH);
http.end();
if (writeError || totalWritten == 0) {
// Clean up partial download
digitalWrite(SDCARD_CS, LOW);
SD.remove(filepath);
digitalWrite(SDCARD_CS, HIGH);
_fetchError = writeError ? "SD write error" : "Empty download";
_downloadOk = false;
} else {
_downloadOk = true;
Serial.printf("WebReader: Downloaded %d bytes to %s\n", totalWritten, filepath);
}
strncpy(_downloadedFile, filename, sizeof(_downloadedFile) - 1);
_downloadedFile[sizeof(_downloadedFile) - 1] = '\0';
_mode = DOWNLOAD_DONE;
// Show result screen
if (_display) {
_display->startFrame();
renderDownloadDone(*_display);
_display->endFrame();
}
return _downloadOk;
}
// ---- HTTP Fetch ----
// Uses ESP32 HTTPClient which works over any active network interface
// (WiFi STA, PPP via 4G modem, etc). The caller is responsible for
// ensuring network connectivity before calling fetchPage().
// Fetch a page with a self-referrer (origin of the URL) for Cloudflare compatibility
bool fetchWithSelfRef(const char* url) {
char selfRef[256];
const char* schEnd = strstr(url, "://");
if (schEnd) {
const char* pathStart = strchr(schEnd + 3, '/');
int oLen = pathStart ? (pathStart - url) : strlen(url);
if (oLen > 254) oLen = 254;
memcpy(selfRef, url, oLen);
selfRef[oLen] = '/';
selfRef[oLen + 1] = '\0';
} else {
strncpy(selfRef, url, 255);
selfRef[255] = '\0';
}
return fetchPage(url, nullptr, nullptr, selfRef);
}
// Translate ESP32 HTTPClient error codes to readable strings
static String httpErrorString(int code) {
if (code == 525) return "SSL error (Cloudflare)";
if (code == 403) return "Blocked (403)";
if (code == 503) return "Unavailable (503)";
if (code > 0) return "HTTP " + String(code);
switch (code) {
case -1: return "Connection refused";
case -2: return "Send header failed";
case -3: return "Send payload failed";
case -4: return "Not connected";
case -5: return "Connection lost";
case -6: return "No stream";
case -7: return "No HTTP server";
case -8: return "Out of RAM";
case -9: return "Encoding error";
case -10: return "Stream write error";
case -11: return "Read timeout";
default: return "Error " + String(code);
}
}
bool fetchPage(const char* url, const char* postBody = nullptr,
const char* contentType = nullptr,
const char* referer = nullptr) {
Serial.printf("WebReader: fetchPage('%s', post=%s, ref=%s)\n",
url, postBody ? "yes" : "no", referer ? referer : "(null)");
// Pause modem polling during fetch to avoid Core 0 contention with
// WiFi/TLS. The modem task's 10ms UART poll loop on Core 0 disrupts
// TLS handshakes, causing Cloudflare 525/503 errors.
#ifdef HAS_4G_MODEM
struct ModemPauseGuard {
ModemPauseGuard() { modemManager.pausePolling(); Serial.println("WebReader: modem paused"); }
~ModemPauseGuard() { modemManager.resumePolling(); Serial.println("WebReader: modem resumed"); }
} _modemGuard;
#endif
if (!allocateBuffers()) {
_fetchError = "Out of memory";
_mode = HOME;
return false;
}
_mode = FETCHING;
_fetchStartTime = millis();
_fetchProgress = 0;
_fetchRetryCount = 0;
_fetchError = "";
// Push current URL to back stack (if we have one)
if (_currentUrl[0] != '\0') {
_backHistory.push_back(String(_currentUrl));
if (_backHistory.size() > 20) _backHistory.erase(_backHistory.begin());
}
// Show the loading screen before blocking fetch
if (_display) {
_display->startFrame();
renderFetching(*_display);
_display->endFrame();
}
// Download HTML
char* htmlBuffer = (char*)ps_malloc(WEB_MAX_PAGE_SIZE);
if (!htmlBuffer) {
_fetchError = "Out of memory (HTML)";
_mode = HOME;
return false;
}
int htmlLen = 0;
bool success = false;
// Extract domain for cookies
char domain[64];
extractDomain(url, domain, sizeof(domain));
String cookieHeader = buildCookieHeader(domain);
// Headers we want to capture from response
const char* collectHeaderNames[] = {"Set-Cookie", "Location", "Content-Type", "Content-Disposition"};
// Manual redirect loop — we handle redirects ourselves to capture
// Set-Cookie headers at each hop. We reuse the TLS client for
// same-host redirects to avoid repeated 5-8s TLS handshakes.
String currentUrl = url;
int redirectCount = 0;
const int maxRedirects = 5;
bool isPost = (postBody != nullptr);
// Use persistent TLS client (member variable) — survives across
// fetchPage() calls for connection reuse to the same host.
ensureTlsUsesPsram();
int totalRetries = 0; // Combined conn + server retries (max 4)
bool needsFreshTls = false; // Deferred TLS client recreation
while (redirectCount <= maxRedirects) {
// Determine current host for TLS session management
bool isHttps = currentUrl.startsWith("https://");
String currentHost;
if (isHttps) {
currentHost = currentUrl.substring(8); // skip "https://"
int slashIdx = currentHost.indexOf('/');
if (slashIdx > 0) currentHost = currentHost.substring(0, slashIdx);
}
// Unified TLS client creation/recreation.
// This runs BEFORE 'HTTPClient http' below is constructed, so it's safe
// to delete _tlsClient (no stack-local HTTPClient holds a reference yet).
// Triggers: error recovery, host change, first use, stale connection.
bool tlsStale = (_tlsClient && !_tlsClient->connected());
if (tlsStale) {
Serial.println("WebReader: TLS connection closed by server, reconnecting");
}
if (isHttps && (needsFreshTls || !_tlsClient || _tlsHost != currentHost || tlsStale)) {
if (_tlsClient) {
_tlsClient->stop();
delete _tlsClient;
}
_tlsClient = new WiFiClientSecure();
_tlsClient->setInsecure();
_tlsClient->setHandshakeTimeout(15);
_tlsHost = currentHost;
needsFreshTls = false;
Serial.printf("WebReader: New TLS session for %s (heap: %d, largest: %d)\n",
currentHost.c_str(), ESP.getFreeHeap(), ESP.getMaxAllocHeap());
}
// Update domain and cookies for current URL
extractDomain(currentUrl.c_str(), domain, sizeof(domain));
cookieHeader = buildCookieHeader(domain);
Serial.printf("WebReader: %s %s (redirect #%d)\n",
isPost ? "POST" : "GET", currentUrl.c_str(), redirectCount);
Serial.printf("WebReader: heap: %d, largest: %d\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
HTTPClient http;
http.setUserAgent(WEB_USER_AGENT);
http.setFollowRedirects(HTTPC_DISABLE_FOLLOW_REDIRECTS);
http.setTimeout(15000); // 15s — fail fast to allow retries
http.setReuse(true); // Keep connection alive for redirects
bool beginOk;
if (isHttps) {
beginOk = http.begin(*_tlsClient, currentUrl);
} else {
beginOk = http.begin(currentUrl);
}
if (!beginOk) {
_fetchError = "Connection failed";
break;
}
// MUST be after begin() — begin() resets collected headers
http.collectHeaders(collectHeaderNames, 4);
if (cookieHeader.length() > 0) {
http.addHeader("Cookie", cookieHeader);
Serial.printf("WebReader: Cookies (%d chars)\n", cookieHeader.length());
}
// Standard browser headers
http.addHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
http.addHeader("Accept-Language", "en-US,en;q=0.9");
http.addHeader("Upgrade-Insecure-Requests", "1");
// Cache-busting: tell Cloudflare to revalidate with origin.
// Using max-age=0 (browser standard) instead of no-cache to avoid
// Cloudflare 525 SSL errors on some origins.
http.addHeader("Cache-Control", "max-age=0");
// Re-render splash before blocking call
if (_display) {
_display->startFrame();
renderFetching(*_display);
_display->endFrame();
}
int httpCode;
if (isPost) {
http.addHeader("Content-Type", contentType ? contentType
: "application/x-www-form-urlencoded");
// Referer = page that contained the form; Origin = scheme+host
const char* ref = referer ? referer : currentUrl.c_str();
http.addHeader("Referer", ref);
char origin[128];
const char* schEnd = strstr(ref, "://");
if (schEnd) {
const char* pathStart = strchr(schEnd + 3, '/');
int oLen = pathStart ? (pathStart - ref) : strlen(ref);
if (oLen > (int)sizeof(origin) - 1) oLen = sizeof(origin) - 1;
memcpy(origin, ref, oLen);
origin[oLen] = '\0';
} else {
strncpy(origin, ref, sizeof(origin) - 1);
origin[sizeof(origin) - 1] = '\0';
}
http.addHeader("Origin", origin);
// Sec-Fetch headers for form submissions
http.addHeader("Sec-Fetch-Dest", "document");
http.addHeader("Sec-Fetch-Mode", "navigate");
http.addHeader("Sec-Fetch-Site", "same-origin");
http.addHeader("Sec-Fetch-User", "?1");
httpCode = http.POST((uint8_t*)postBody, strlen(postBody));
Serial.printf("WebReader: POST -> %d (Referer: %s)\n", httpCode, ref);
} else {
// Send Referer on GET too — helps with Cloudflare bot detection
if (referer && referer[0]) {
http.addHeader("Referer", referer);
}
httpCode = http.GET();
Serial.printf("WebReader: GET -> %d\n", httpCode);
}
// Capture all Set-Cookie headers from this response
captureResponseCookies(http, domain);
// Connection-level failure (timeout, TLS error, etc)
if (httpCode < 0 && totalRetries < 4) {
http.end();
totalRetries++;
_fetchRetryCount++;
needsFreshTls = true; // Recreate at top of next iteration (after http destructor)
// After 2 failures, try WiFi reconnect — the lwIP stack may be wedged
// Skip on WiFi companion variant — disconnect kills the companion TCP server
#ifndef MECK_WIFI_COMPANION
if (totalRetries == 2 && isWiFiConnected()) {
Serial.println("WebReader: WiFi reconnect after persistent failures");
// Destroy TLS before WiFi teardown
if (_tlsClient) { delete _tlsClient; _tlsClient = nullptr; }
_tlsHost = "";
WiFi.disconnect(false);
delay(500);
WiFi.reconnect();
unsigned long wt = millis();
while (WiFi.status() != WL_CONNECTED && millis() - wt < 8000) delay(100);
if (WiFi.status() != WL_CONNECTED) {
Serial.println("WebReader: WiFi reconnect failed");
_fetchError = "WiFi reconnect failed";
break;
}
Serial.printf("WebReader: WiFi reconnected, IP: %s\n",
WiFi.localIP().toString().c_str());
}
#endif
int retryDelay = 1000 + totalRetries * 1000; // 2s, 3s, 4s, 5s
Serial.printf("WebReader: Connection error %d, retrying in %dms... (attempt %d/4)\n",
httpCode, retryDelay, totalRetries);
if (_display) {
_display->startFrame();
renderFetching(*_display);
_display->endFrame();
}
delay(retryDelay);
continue;
}
if (httpCode < 0) {
_fetchError = httpErrorString(httpCode);
http.end();
break;
}
// Handle redirects
if (httpCode == 301 || httpCode == 302 || httpCode == 303 || httpCode == 307) {
String location = http.header("Location");
// End the connection — the next loop iteration creates a fresh
// HTTPClient. Don't use getString() to "consume" the body because
// chunked responses without proper termination can hang forever.
http.end();
if (location.length() == 0) {
_fetchError = "Redirect with no Location";
break;
}
char resolved[WEB_MAX_URL_LEN];
resolveUrl(currentUrl.c_str(), location.c_str(), resolved, WEB_MAX_URL_LEN);
currentUrl = resolved;
if (httpCode == 302 || httpCode == 303) isPost = false;
redirectCount++;
Serial.printf("WebReader: Redirect -> %s\n", currentUrl.c_str());
continue;
}
if (httpCode == HTTP_CODE_OK) {
// Check if this is a file download (EPUB, etc.) rather than HTML
String ctype = http.header("Content-Type");
String cdisp = http.header("Content-Disposition");
ctype.toLowerCase();
bool isEpubUrl = currentUrl.endsWith(".epub");
bool isEpubContent = ctype.indexOf("epub") >= 0 ||
(ctype.indexOf("octet-stream") >= 0 && isEpubUrl);
bool isDownload = cdisp.indexOf("attachment") >= 0;
if (isEpubUrl || isEpubContent || (isDownload && isEpubUrl)) {
Serial.println("WebReader: EPUB detected, downloading to SD");
free(htmlBuffer);
bool dlOk = downloadToSD(http, currentUrl.c_str(), cdisp);
// _tlsClient persists as member — cleaned up by exitReader()
return dlOk;
}
htmlLen = readResponseBody(http, htmlBuffer, WEB_MAX_PAGE_SIZE);
success = (htmlLen > 0);
http.end();
break;
} else if (httpCode >= 500 && httpCode < 600 && totalRetries < 4) {
// Server error (e.g. Cloudflare 525/503)
// Don't call getString() — CF error responses can use chunked encoding
// without proper termination, causing getString() to block forever.
// http.end() forcefully closes the socket which is fine since we're
// recreating the TLS client anyway.
http.end();
needsFreshTls = true; // Recreate at top of next iteration
totalRetries++;
_fetchRetryCount++;
// Clear Cloudflare cookies — stale __cf_bm tokens from failed
// handshakes cause CF to keep rejecting us
clearCloudflareCookies(domain);
// WiFi reconnect after 2 total failures (same logic as conn errors)
// Skip on WiFi companion variant — disconnect kills the companion TCP server
#ifndef MECK_WIFI_COMPANION
if (totalRetries == 2 && isWiFiConnected()) {
Serial.println("WebReader: WiFi reconnect after persistent failures");
if (_tlsClient) { delete _tlsClient; _tlsClient = nullptr; }
_tlsHost = "";
WiFi.disconnect(false);
delay(500);
WiFi.reconnect();
unsigned long wt = millis();
while (WiFi.status() != WL_CONNECTED && millis() - wt < 8000) delay(100);
if (WiFi.status() != WL_CONNECTED) {
_fetchError = "WiFi reconnect failed";
break;
}
Serial.printf("WebReader: WiFi reconnected, IP: %s\n",
WiFi.localIP().toString().c_str());
}
#endif
int retryDelay = 1000 + totalRetries * 1000; // 2s, 3s, 4s, 5s
Serial.printf("WebReader: Server error %d, retrying in %dms... (attempt %d/4)\n",
httpCode, retryDelay, totalRetries);
if (_display) {
_display->startFrame();
renderFetching(*_display);
_display->endFrame();
}
delay(retryDelay);
continue;
} else {
_fetchError = httpErrorString(httpCode);
http.end();
break;
}
} // end redirect loop
// Don't delete _tlsClient — keep for connection reuse on next fetchPage().
// Cleaned up by exitReader() or on next fetch to a different host.
if (redirectCount > maxRedirects && !success) {
_fetchError = "Too many redirects";
}
if (!success) {
free(htmlBuffer);
Serial.printf("WebReader: Fetch failed: %s\n", _fetchError.c_str());
_mode = HOME;
// Show error briefly then return to URL entry
if (_display) {
_display->startFrame();
_display->setColor(DisplayDriver::GREEN);
_display->setTextSize(1);
_display->setCursor(0, 0);
_display->print("Web Reader");
_display->drawRect(0, 11, _display->width(), 1);
_display->setColor(DisplayDriver::YELLOW);
_display->setTextSize(_prefs->smallTextSize());
_display->setCursor(0, 18);
_display->print("Fetch failed:");
_display->setColor(DisplayDriver::LIGHT);
_display->setCursor(0, 30);
// Word-wrap error message
String errMsg = _fetchError;
int y2 = 30;
while (errMsg.length() > 0 && y2 < 60) {
String line = errMsg.substring(0, _charsPerLine);
errMsg = errMsg.substring(line.length());
_display->setCursor(0, y2);
_display->print(line.c_str());
y2 += 8;
}
_display->setCursor(0, 70);
_display->print(_urlBuffer);
_display->setCursor(0, 90);
_display->setColor(DisplayDriver::GREEN);
_display->print("Returning to URL entry...");
_display->endFrame();
}
delay(2500);
return false;
}
htmlBuffer[htmlLen] = '\0';
// Extract <title> if present
_pageTitle[0] = '\0';
const char* titleStart = strcasestr(htmlBuffer, "<title>");
if (titleStart) {
titleStart += 7;
const char* titleEnd = strcasestr(titleStart, "</title>");
if (titleEnd) {
int titleLen = titleEnd - titleStart;
if (titleLen > (int)sizeof(_pageTitle) - 1)
titleLen = sizeof(_pageTitle) - 1;
memcpy(_pageTitle, titleStart, titleLen);
_pageTitle[titleLen] = '\0';
}
}
// Parse HTML to text
ParseResult pr = parseHtml(htmlBuffer, htmlLen, _textBuffer, WEB_MAX_TEXT_SIZE,
_links, WEB_MAX_LINKS,
_forms, WEB_MAX_FORMS, currentUrl.c_str());
_textLen = pr.textLen;
_linkCount = pr.linkCount;
_formCount = pr.formCount;
Serial.printf("WebReader: Parsed %d chars, %d links, %d forms\n",
_textLen, _linkCount, _formCount);
free(htmlBuffer);
// Update URL bar with final URL (may differ from original after redirects)
strncpy(_urlBuffer, currentUrl.c_str(), WEB_MAX_URL_LEN - 1);
_urlLen = strlen(_urlBuffer);
// Store current URL (final URL after any redirects)
strncpy(_currentUrl, currentUrl.c_str(), WEB_MAX_URL_LEN - 1);
_currentUrl[WEB_MAX_URL_LEN - 1] = '\0';
Serial.printf("WebReader: _currentUrl set to: %s\n", _currentUrl);
// Add to history (final URL after redirects)
addToHistory(currentUrl.c_str());
// Paginate
paginateText();
Serial.printf("WebReader: Fetched %s - %d chars text, %d links, %d forms, %d pages\n",
currentUrl.c_str(), _textLen, _linkCount, _formCount, _totalPages);
_mode = READING;
_currentPage = 0;
return true;
}
// Submit a form via GET or POST
bool submitForm(int formIdx) {
if (formIdx < 0 || formIdx >= _formCount) return false;
WebForm& form = _forms[formIdx];
Serial.printf("WebReader: Submitting form %d (%s) to %s\n",
formIdx, form.isPost ? "POST" : "GET", form.action);
Serial.printf("WebReader: Referer will be: %s\n", _currentUrl);
Serial.printf("WebReader: Cookie jar has %d cookies:\n", _cookieCount);
for (int c = 0; c < _cookieCount; c++) {
Serial.printf(" [%d] %s = %.30s... (domain=%s)\n",
c, _cookies[c].name, _cookies[c].value, _cookies[c].domain);
}
if (form.isPost) {
// Save user-entered field values before first POST attempt.
// After fetchPage(), the form structures get overwritten by the new page.
// We need these to retry if CSRF fails (stale token from cached page).
struct SavedField { char name[32]; char value[WEB_MAX_FIELD_VALUE]; };
SavedField savedFields[WEB_MAX_FORM_FIELDS];
int savedCount = form.fieldCount;
char savedAction[WEB_MAX_URL_LEN];
char savedReferer[WEB_MAX_URL_LEN];
strncpy(savedAction, form.action, WEB_MAX_URL_LEN - 1);
savedAction[WEB_MAX_URL_LEN - 1] = '\0';
strncpy(savedReferer, _currentUrl, WEB_MAX_URL_LEN - 1);
savedReferer[WEB_MAX_URL_LEN - 1] = '\0';
for (int f = 0; f < form.fieldCount && f < WEB_MAX_FORM_FIELDS; f++) {
strncpy(savedFields[f].name, form.fields[f].name, 31);
savedFields[f].name[31] = '\0';
strncpy(savedFields[f].value, form.fields[f].value, WEB_MAX_FIELD_VALUE - 1);
savedFields[f].value[WEB_MAX_FIELD_VALUE - 1] = '\0';
}
String body = buildFormBody(form);
Serial.printf("WebReader: POST body (%d bytes): %s\n",
body.length(), body.c_str());
strncpy(_urlBuffer, form.action, WEB_MAX_URL_LEN - 1);
_urlLen = strlen(_urlBuffer);
// --- First POST attempt ---
// On Cloudflare-cached sites, this may fail because the CSRF token
// came from a cached page with no session. But the 302 response
// WILL set _otwarchive_session, creating the session we need.
bool result = fetchPage(form.action, body.c_str(), nullptr, _currentUrl);
// Check if we got redirected to an auth error page
if (strstr(_currentUrl, "auth_error") || strstr(_currentUrl, "session_expired")) {
Serial.println("WebReader: Auth error detected — CSRF token was stale (cached page)");
Serial.println("WebReader: Retrying with fresh session cookie...");
// Show retry status on display
if (_display) {
_display->startFrame();
_display->setColor(DisplayDriver::GREEN);
_display->setTextSize(2);
_display->setCursor(10, 20);
_display->print("Logging in...");
_display->setTextSize(_prefs->smallTextSize());
_display->setColor(DisplayDriver::LIGHT);
_display->setCursor(10, 45);
_display->print("Refreshing session...");
_display->endFrame();
}
// Re-fetch the original form page.
// Now that we have _otwarchive_session cookie, Cloudflare should
// bypass its cache and serve a fresh page from AO3's origin with
// a CSRF token that matches our session.
Serial.printf("WebReader: Re-fetching form page: %s\n", savedReferer);
result = fetchWithSelfRef(savedReferer);
if (result && _formCount > 0) {
// Find the login form and update its fields with saved user data
int retryForm = -1;
for (int fi = 0; fi < _formCount; fi++) {
// Match by action URL
if (strstr(_forms[fi].action, "login") || strstr(_forms[fi].action, "session")) {
retryForm = fi;
break;
}
}
if (retryForm < 0) retryForm = 0; // fallback to first form
WebForm& newForm = _forms[retryForm];
Serial.printf("WebReader: Found retry form %d with %d fields, action: %s\n",
retryForm, newForm.fieldCount, newForm.action);
// Copy saved user values into matching fields of new form.
// Skip CSRF tokens (they have fresh values from the re-fetch).
for (int sf = 0; sf < savedCount; sf++) {
// Skip CSRF-type fields — new form already has fresh token
if (strcmp(savedFields[sf].name, "authenticity_token") == 0 ||
strcmp(savedFields[sf].name, "csrf_token") == 0 ||
strcmp(savedFields[sf].name, "_token") == 0 ||
strcmp(savedFields[sf].name, "commit") == 0) {
continue;
}
// Find matching field in new form
for (int nf = 0; nf < newForm.fieldCount; nf++) {
if (strcmp(newForm.fields[nf].name, savedFields[sf].name) == 0) {
strncpy(newForm.fields[nf].value, savedFields[sf].value,
WEB_MAX_FIELD_VALUE - 1);
Serial.printf("WebReader: Restored field '%s'\n", savedFields[sf].name);
break;
}
}
}
// Build new POST body with fresh CSRF token + saved user data
String retryBody = buildFormBody(newForm);
Serial.printf("WebReader: Retry POST body (%d bytes): %s\n",
retryBody.length(), retryBody.c_str());
Serial.printf("WebReader: Cookie jar has %d cookies:\n", _cookieCount);
for (int c = 0; c < _cookieCount; c++) {
Serial.printf(" [%d] %s = %.30s... (domain=%s)\n",
c, _cookies[c].name, _cookies[c].value, _cookies[c].domain);
}
strncpy(_urlBuffer, newForm.action, WEB_MAX_URL_LEN - 1);
_urlLen = strlen(_urlBuffer);
result = fetchPage(newForm.action, retryBody.c_str(), nullptr, savedReferer);
} else {
Serial.println("WebReader: Re-fetch failed or no forms found for retry");
}
}
return result;
} else {
// GET - append form data as query string
String getUrl = form.action;
String body = buildFormBody(form);
if (body.length() > 0) {
getUrl += (getUrl.indexOf('?') >= 0) ? "&" : "?";
getUrl += body;
}
strncpy(_urlBuffer, getUrl.c_str(), WEB_MAX_URL_LEN - 1);
_urlLen = strlen(_urlBuffer);
return fetchPage(getUrl.c_str(), nullptr, nullptr, _currentUrl);
}
}
// strcasestr implementation (not always available)
static const char* strcasestr(const char* haystack, const char* needle) {
if (!needle[0]) return haystack;
for (const char* p = haystack; *p; p++) {
const char* h = p;
const char* n = needle;
while (*h && *n) {
char hc = *h; if (hc >= 'A' && hc <= 'Z') hc += 32;
char nc = *n; if (nc >= 'A' && nc <= 'Z') nc += 32;
if (hc != nc) break;
h++; n++;
}
if (!*n) return p;
}
return nullptr;
}
// ---- Pagination ----
void paginateText() {
_pageOffsets.clear();
_pageOffsets.push_back(0);
int pos = 0;
while (pos < _textLen) {
int lineCount = 0;
while (lineCount < _linesPerPage && pos < _textLen) {
WebWrapResult wrap = webFindLineBreak(_textBuffer, _textLen, pos, _charsPerLine);
if (wrap.nextStart <= pos) {
pos = _textLen; // Safety: prevent infinite loop
break;
}
pos = wrap.nextStart;
lineCount++;
}
if (pos < _textLen) {
_pageOffsets.push_back(pos);
}
}
_totalPages = _pageOffsets.size();
_currentPage = 0;
}
// ---- Bookmarks & History ----
void loadBookmarks() {
_bookmarks.clear();
File f = SD.open(WEB_BOOKMARKS_FILE, FILE_READ);
if (!f) { digitalWrite(SDCARD_CS, HIGH); return; }
while (f.available() && _bookmarks.size() < WEB_MAX_BOOKMARKS) {
String line = f.readStringUntil('\n');
line.trim();
if (line.length() > 0) _bookmarks.push_back(line);
}
f.close();
digitalWrite(SDCARD_CS, HIGH);
}
void saveBookmarks() {
if (!SD.exists(WEB_CACHE_DIR)) SD.mkdir(WEB_CACHE_DIR);
File f = SD.open(WEB_BOOKMARKS_FILE, FILE_WRITE);
if (!f) return;
for (auto& b : _bookmarks) {
f.println(b);
}
f.close();
digitalWrite(SDCARD_CS, HIGH);
}
void addBookmark(const char* url) {
// Remove if already exists
for (int i = 0; i < (int)_bookmarks.size(); i++) {
if (_bookmarks[i] == url) {
_bookmarks.erase(_bookmarks.begin() + i);
break;
}
}
// Add at front
_bookmarks.insert(_bookmarks.begin(), String(url));
if (_bookmarks.size() > WEB_MAX_BOOKMARKS) _bookmarks.pop_back();
saveBookmarks();
}
void loadHistory() {
_history.clear();
File f = SD.open(WEB_HISTORY_FILE, FILE_READ);
if (!f) { digitalWrite(SDCARD_CS, HIGH); return; }
while (f.available() && _history.size() < WEB_MAX_HISTORY) {
String line = f.readStringUntil('\n');
line.trim();
if (line.length() > 0) _history.push_back(line);
}
f.close();
digitalWrite(SDCARD_CS, HIGH);
}
void saveHistory() {
if (!SD.exists(WEB_CACHE_DIR)) SD.mkdir(WEB_CACHE_DIR);
File f = SD.open(WEB_HISTORY_FILE, FILE_WRITE);
if (!f) return;
for (auto& h : _history) {
f.println(h);
}
f.close();
digitalWrite(SDCARD_CS, HIGH);
}
void addToHistory(const char* url) {
// Remove duplicates
for (int i = 0; i < (int)_history.size(); i++) {
if (_history[i] == url) {
_history.erase(_history.begin() + i);
break;
}
}
_history.insert(_history.begin(), String(url));
if (_history.size() > WEB_MAX_HISTORY) _history.pop_back();
saveHistory();
}
// ---- Rendering Helpers ----
void renderWifiSetup(DisplayDriver& display) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.setCursor(0, 0);
display.print("WiFi Setup");
display.drawRect(0, 11, display.width(), 1);
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(_prefs->smallTextSize());
if (_wifiState == WIFI_SCANNING) {
display.setCursor(0, 18);
display.print("Scanning for networks...");
} else if (_wifiState == WIFI_SCAN_DONE) {
int y = 14;
int listLineH = _prefs ? _prefs->smallLineH() : 9;
for (int i = 0; i < _ssidCount && y < display.height() - 24; i++) {
bool selected = (i == _selectedSSID);
if (selected) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), listLineH);
#else
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width(), listLineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(0, y);
String line = selected ? "> " : " ";
line += _ssidList[i];
if ((int)line.length() > _charsPerLine)
line = line.substring(0, _charsPerLine - 3) + "...";
display.print(line.c_str());
y += listLineH;
}
} else if (_wifiState == WIFI_ENTERING_PASS) {
int y = 14;
display.setCursor(0, y);
display.setColor(DisplayDriver::LIGHT);
char tmp[64];
snprintf(tmp, sizeof(tmp), "SSID: %s", _ssidList[_selectedSSID].c_str());
display.print(tmp);
y += 12;
display.setCursor(0, y);
display.print("Password:");
y += _prefs->smallLineH() + 1;
display.setCursor(0, y);
// Show masked password with brief reveal of last char
char passBuf[WEB_WIFI_PASS_LEN + 2];
for (int pi = 0; pi < _wifiPassLen; pi++) {
passBuf[pi] = '*';
}
// Brief reveal: show last char for ~800ms after typing
if (_wifiPassLen > 0 && _formLastCharAt > 0 &&
(millis() - _formLastCharAt) < 800) {
passBuf[_wifiPassLen - 1] = _wifiPass[_wifiPassLen - 1];
}
passBuf[_wifiPassLen] = '_'; // Cursor
passBuf[_wifiPassLen + 1] = '\0';
display.print(passBuf);
} else if (_wifiState == WIFI_CONNECTING) {
display.setCursor(0, 18);
display.print("Connecting...");
display.setCursor(0, 30);
char tmp[48];
snprintf(tmp, sizeof(tmp), "SSID: %s", _connectedSSID.c_str());
display.print(tmp);
} else if (_wifiState == WIFI_FAILED) {
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, 18);
display.print("WiFi Error:");
display.setCursor(0, 30);
display.setColor(DisplayDriver::LIGHT);
// Word-wrap the error message
String err = _fetchError;
int y2 = 30;
while (err.length() > 0 && y2 < 70) {
String line;
if ((int)err.length() <= _charsPerLine) {
line = err; err = "";
} else {
line = err.substring(0, _charsPerLine);
err = err.substring(_charsPerLine);
}
display.setCursor(0, y2);
display.print(line.c_str());
y2 += 8;
}
display.setCursor(0, 80);
display.setColor(DisplayDriver::GREEN);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Tap: Retry");
#else
display.print("Enter: Retry Q: Back");
#endif
}
// Footer
display.setTextSize(1);
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setCursor(0, footerY);
display.setColor(DisplayDriver::YELLOW);
#if defined(LilyGo_T5S3_EPaper_Pro)
if (_wifiState == WIFI_ENTERING_PASS)
display.print("Tap: Enter Password Hold: Back");
else
display.print("Swipe: Navigate Tap: Select");
#else
display.print("Q:Back W/S:Nav Ent:Select");
#endif
}
void renderHome(DisplayDriver& display) {
// ---- Fixed header (not scrolled) ----
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.setCursor(0, 0);
if (isNetworkAvailable()) {
display.print("Web Reader");
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::GREEN);
if (isWiFiConnected()) {
IPAddress ip = WiFi.localIP();
char ipStr[20];
snprintf(ipStr, sizeof(ipStr), "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
display.setCursor(display.width() - display.getTextWidth(ipStr) - 2, -3);
display.print(ipStr);
} else {
const char* netStr = "4G";
display.setCursor(display.width() - display.getTextWidth(netStr) - 2, -3);
display.print(netStr);
}
} else {
display.print("Web Reader (Offline)");
}
display.setTextSize(1);
display.drawRect(0, 11, display.width(), 1);
// ---- Layout constants ----
const int headerY = 14; // Content starts here
const int footerH = 14;
const int footerY = display.height() - 12;
const int viewportH = display.height() - headerY - footerH;
const int scrollbarW = 4;
const int listLineH = _prefs ? _prefs->smallLineH() : 9;
const int sepH = 8; // Separator between IRC and web sections
const int sectionH = listLineH; // Section header height
int maxChars = _charsPerLine - 2; // Account for "> " prefix
if (maxChars < 10) maxChars = 10;
int totalItems = 3 + (int)_bookmarks.size() + (int)_history.size();
// ---- Layout pass: compute virtual Y extent of each item ----
// We track: for each selectable item, its (virtualY, height).
// Non-selectable elements (separators, section headers) are accounted
// for in the gaps between items.
int virtualY = 0;
int itemIdx = 0;
int selectedTop = 0, selectedBot = 0;
// Item 0: IRC
int ircH = listLineH + 2;
if (itemIdx == _homeSelected) { selectedTop = virtualY; selectedBot = virtualY + ircH; }
virtualY += ircH;
itemIdx++;
// Separator
virtualY += sepH;
// Item 1: URL bar
int urlBarH = listLineH + 2;
if (itemIdx == _homeSelected) { selectedTop = virtualY; selectedBot = virtualY + urlBarH; }
virtualY += urlBarH;
itemIdx++;
// Item 2: Search bar
int searchBarH = listLineH + 2;
if (itemIdx == _homeSelected) { selectedTop = virtualY; selectedBot = virtualY + searchBarH; }
virtualY += searchBarH;
itemIdx++;
// Bookmarks
if (_bookmarks.size() > 0) {
virtualY += sectionH; // "-- Bookmarks --" header
for (int i = 0; i < (int)_bookmarks.size(); i++) {
int urlLen = _bookmarks[i].length();
int numLines = (urlLen + maxChars - 1) / maxChars;
if (numLines < 1) numLines = 1;
int h = numLines * listLineH;
if (itemIdx == _homeSelected) { selectedTop = virtualY; selectedBot = virtualY + h; }
virtualY += h;
itemIdx++;
}
}
// History
if (_history.size() > 0) {
virtualY += sectionH; // "-- History --" header
for (int i = 0; i < (int)_history.size(); i++) {
int urlLen = _history[i].length();
int numLines = (urlLen + maxChars - 1) / maxChars;
if (numLines < 1) numLines = 1;
int h = numLines * listLineH;
if (itemIdx == _homeSelected) { selectedTop = virtualY; selectedBot = virtualY + h; }
virtualY += h;
itemIdx++;
}
}
int totalContentH = virtualY;
// ---- Adjust scroll to keep selected item visible ----
if (selectedTop < _homeScrollY) {
_homeScrollY = selectedTop;
}
if (selectedBot > _homeScrollY + viewportH) {
_homeScrollY = selectedBot - viewportH;
}
if (_homeScrollY < 0) _homeScrollY = 0;
if (totalContentH <= viewportH) _homeScrollY = 0;
// ---- Render pass (with scroll offset) ----
display.setTextSize(_prefs->smallTextSize());
int y = headerY - _homeScrollY; // Start Y in screen coords
itemIdx = 0;
bool needsScroll = (totalContentH > viewportH);
// Clip region: only draw items with y between headerY and headerY+viewportH
int clipTop = headerY;
int clipBot = headerY + viewportH;
// Helper: check if a rect at (y, height) is at least partially visible
#define HOME_VISIBLE(yy, hh) ((yy) + (hh) > clipTop && (yy) < clipBot)
// Item 0: IRC Chat
{
bool selected = (_homeSelected == itemIdx);
if (HOME_VISIBLE(y, ircH)) {
if (selected) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
#else
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::GREEN);
}
display.setCursor(0, y);
if (_ircConnected && _ircJoined) {
char ircLabel[80];
snprintf(ircLabel, sizeof(ircLabel), "IRC: %s [connected]", _ircChannel);
display.print(ircLabel);
} else if (_ircConnected) {
display.print("IRC: connecting...");
} else {
char ircLabel[80];
snprintf(ircLabel, sizeof(ircLabel), "IRC: %s:%d", _ircHost, _ircPort);
display.print(ircLabel);
}
}
y += ircH;
itemIdx++;
}
// Separator
if (HOME_VISIBLE(y, sepH)) {
display.setColor(DisplayDriver::GREEN);
display.drawRect(0, y + 5, display.width() - (needsScroll ? scrollbarW + 1 : 0), 1);
}
y += sepH;
// Item 1: URL bar
{
bool selected = (_homeSelected == itemIdx);
if (HOME_VISIBLE(y, urlBarH)) {
if (selected) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
#else
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(0, y);
if (_urlEditing) {
char urlDisp[WEB_MAX_URL_LEN + 2];
int maxShow = maxChars - 3; // "Web: " prefix
int start = 0;
if (_urlLen > maxShow) start = _urlLen - maxShow;
snprintf(urlDisp, sizeof(urlDisp), "Web: %s_", _urlBuffer + start);
display.print(urlDisp);
} else if (_urlLen > 0) {
char urlDisp[80];
int maxShow = maxChars - 3;
snprintf(urlDisp, sizeof(urlDisp), "Web: %s",
_urlLen > maxShow ? (_urlBuffer + _urlLen - maxShow) : _urlBuffer);
display.print(urlDisp);
} else {
display.print("Web: [Enter URL]");
}
}
y += urlBarH;
itemIdx++;
}
// Item 2: Search bar
{
bool selected = (_homeSelected == itemIdx);
if (HOME_VISIBLE(y, searchBarH)) {
if (selected) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
#else
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width() - (needsScroll ? scrollbarW + 1 : 0), listLineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(0, y);
if (_searchEditing) {
char searchDisp[140];
int maxShow = maxChars - 8; // "Search: " prefix + cursor
int start = 0;
if (_searchLen > maxShow) start = _searchLen - maxShow;
snprintf(searchDisp, sizeof(searchDisp), "Search: %s_", _searchBuffer + start);
display.print(searchDisp);
} else if (_searchLen > 0) {
char searchDisp[140];
int maxShow = maxChars - 7;
snprintf(searchDisp, sizeof(searchDisp), "Search: %s",
_searchLen > maxShow ? (_searchBuffer + _searchLen - maxShow) : _searchBuffer);
display.print(searchDisp);
} else {
display.print("Search: [DuckDuckGo Lite]");
}
}
y += searchBarH;
itemIdx++;
}
// Bookmarks section
if (_bookmarks.size() > 0) {
// Section header
if (HOME_VISIBLE(y, sectionH)) {
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, y);
display.print("-- Bookmarks --");
}
y += sectionH;
for (int i = 0; i < (int)_bookmarks.size(); i++) {
bool selected = (_homeSelected == itemIdx);
const char* url = _bookmarks[i].c_str();
int urlLen = strlen(url);
int numLines = (urlLen + maxChars - 1) / maxChars;
if (numLines < 1) numLines = 1;
int itemH = numLines * listLineH;
if (HOME_VISIBLE(y, itemH)) {
int contentW = display.width() - (needsScroll ? scrollbarW + 1 : 0);
if (selected) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, contentW, itemH);
#else
display.fillRect(0, y + _prefs->smallHighlightOff(), contentW, itemH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
int off = 0;
for (int ln = 0; ln < numLines; ln++) {
int lineY = y + ln * listLineH;
if (lineY >= clipTop && lineY < clipBot) {
display.setCursor(0, lineY);
char lineBuf[128];
const char* prefix = (ln == 0) ? (selected ? "> " : " ") : " ";
int chars = maxChars;
if (urlLen - off < chars) chars = urlLen - off;
snprintf(lineBuf, sizeof(lineBuf), "%s%.*s", prefix, chars, url + off);
display.print(lineBuf);
}
off += maxChars;
}
}
y += itemH;
itemIdx++;
}
}
// History section
if (_history.size() > 0) {
// Section header
if (HOME_VISIBLE(y, sectionH)) {
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, y);
display.print("-- History --");
}
y += sectionH;
for (int i = 0; i < (int)_history.size(); i++) {
bool selected = (_homeSelected == itemIdx);
const char* url = _history[i].c_str();
int urlLen = strlen(url);
int numLines = (urlLen + maxChars - 1) / maxChars;
if (numLines < 1) numLines = 1;
int itemH = numLines * listLineH;
if (HOME_VISIBLE(y, itemH)) {
int contentW = display.width() - (needsScroll ? scrollbarW + 1 : 0);
if (selected) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, contentW, itemH);
#else
display.fillRect(0, y + _prefs->smallHighlightOff(), contentW, itemH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
int off = 0;
for (int ln = 0; ln < numLines; ln++) {
int lineY = y + ln * listLineH;
if (lineY >= clipTop && lineY < clipBot) {
display.setCursor(0, lineY);
char lineBuf[128];
const char* prefix = (ln == 0) ? (selected ? "> " : " ") : " ";
int chars = maxChars;
if (urlLen - off < chars) chars = urlLen - off;
snprintf(lineBuf, sizeof(lineBuf), "%s%.*s", prefix, chars, url + off);
display.print(lineBuf);
}
off += maxChars;
}
}
y += itemH;
itemIdx++;
}
}
#undef HOME_VISIBLE
// ---- Scrollbar ----
if (needsScroll) {
int sbX = display.width() - scrollbarW;
int sbTop = headerY;
int sbH = viewportH;
// Track line
display.setColor(DisplayDriver::DARK);
display.drawRect(sbX, sbTop, 1, sbH);
// Thumb
int thumbH = (viewportH * viewportH) / totalContentH;
if (thumbH < 6) thumbH = 6;
if (thumbH > sbH) thumbH = sbH;
int scrollRange = totalContentH - viewportH;
int thumbY = sbTop;
if (scrollRange > 0) {
thumbY = sbTop + (_homeScrollY * (sbH - thumbH)) / scrollRange;
}
display.setColor(DisplayDriver::GREEN);
display.fillRect(sbX, thumbY, scrollbarW, thumbH);
}
// ---- Footer (fixed, not scrolled) ----
// Clear footer area in case scrolled content bleeds into it
display.setColor(DisplayDriver::DARK);
display.fillRect(0, footerY - 2, display.width(), footerH + 2);
display.setTextSize(1);
display.drawRect(0, footerY - 2, display.width(), 1);
display.setCursor(0, footerY);
display.setColor(DisplayDriver::YELLOW);
if (_urlEditing) {
display.print("Type URL Ent:Go");
} else if (_searchEditing) {
display.print("Type query Ent:Search");
} else {
char footerBuf[48];
#if defined(LilyGo_T5S3_EPaper_Pro)
bool onBookmark = (_homeSelected >= 3 && _homeSelected < 3 + (int)_bookmarks.size());
bool onUrl = (_homeSelected == 1);
bool onSearch = (_homeSelected == 2);
if (onUrl)
snprintf(footerBuf, sizeof(footerBuf), "Tap: Enter URL Hold: Back");
else if (onSearch)
snprintf(footerBuf, sizeof(footerBuf), "Tap: Search Hold: Back");
else if (onBookmark)
snprintf(footerBuf, sizeof(footerBuf), "Swipe: Navigate Tap: Open Hold: Delete");
else
snprintf(footerBuf, sizeof(footerBuf), "Swipe: Navigate Tap: Open Hold: Exit");
#else
bool hasData = (_cookieCount > 0 || !_history.empty());
bool onBookmark = (_homeSelected >= 3 && _homeSelected < 3 + (int)_bookmarks.size());
if (onBookmark && hasData)
snprintf(footerBuf, sizeof(footerBuf), "Ent:Go Del:Del Bkmk X:Clr Ckies");
else if (onBookmark)
snprintf(footerBuf, sizeof(footerBuf), "Q:Bk Ent:Go Del:Del Bkmk");
else if (hasData)
snprintf(footerBuf, sizeof(footerBuf), "Q:Bk W/S Ent:Go X:Clr Ckies");
else
snprintf(footerBuf, sizeof(footerBuf), "Q:Bk W/S:Nav Ent:Go");
#endif
display.print(footerBuf);
}
// Toast notification overlay (for bookmark deleted, etc.)
if (_toastMsg[0] && (millis() - _toastTime < 1500)) {
display.setTextSize(1);
int tw = display.getTextWidth(_toastMsg);
int bw = tw + 16;
int bh = 20;
int bx = (display.width() - bw) / 2;
int by = (display.height() - bh) / 2;
display.setColor(DisplayDriver::DARK);
display.fillRect(bx, by, bw, bh);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(bx, by, bw, 1);
display.drawRect(bx, by + bh - 1, bw, 1);
display.drawRect(bx, by, 1, bh);
display.drawRect(bx + bw - 1, by, 1, bh);
display.setCursor(bx + 8, by + 5);
display.print(_toastMsg);
} else if (_toastMsg[0]) {
_toastMsg[0] = '\0';
}
}
void renderFetching(DisplayDriver& display) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(2);
display.setCursor(10, 20);
display.print("Loading...");
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
// Word-wrap the URL across multiple lines
int urlLen = strlen(_urlBuffer);
int y = 45;
int off = 0;
int maxChars = _charsPerLine > 2 ? _charsPerLine - 2 : 30; // small margin
while (off < urlLen && y < 85) {
int lineLen = urlLen - off;
if (lineLen > maxChars) lineLen = maxChars;
char lineBuf[128];
snprintf(lineBuf, sizeof(lineBuf), "%.*s", lineLen, _urlBuffer + off);
display.setCursor(10, y);
display.print(lineBuf);
off += lineLen;
y += 8;
}
display.setCursor(10, y + 4);
display.setTextSize(1);
char progBuf[48];
int elapsed = (int)((millis() - _fetchStartTime) / 1000);
if (_fetchRetryCount > 0) {
snprintf(progBuf, sizeof(progBuf), "Retry %d/4... %ds", _fetchRetryCount, elapsed);
display.setColor(DisplayDriver::YELLOW);
} else if (_fetchProgress > 0) {
snprintf(progBuf, sizeof(progBuf), "%d bytes (%ds)", _fetchProgress, elapsed);
} else if (elapsed >= 2) {
snprintf(progBuf, sizeof(progBuf), "Connecting... %ds", elapsed);
} else {
snprintf(progBuf, sizeof(progBuf), "Connecting...");
}
display.print(progBuf);
}
void renderDownloadDone(DisplayDriver& display) {
display.setTextSize(1);
display.setCursor(0, 0);
if (_downloadOk) {
display.setColor(DisplayDriver::GREEN);
display.print("Download Complete");
display.drawRect(0, 11, display.width(), 1);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, 16);
display.print("Saved to /books/:");
display.setCursor(0, 26);
// Word-wrap filename
int fnLen = strlen(_downloadedFile);
int off = 0;
int y = 26;
while (off < fnLen && y < 54) {
int lineLen = min(_charsPerLine, fnLen - off);
char lineBuf[64];
snprintf(lineBuf, sizeof(lineBuf), "%.*s", lineLen, _downloadedFile + off);
display.setCursor(0, y);
display.print(lineBuf);
off += lineLen;
y += 8;
}
display.setCursor(0, y + 6);
display.setColor(DisplayDriver::GREEN);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Tap: Open in Reader");
#else
display.print("Ent: Open in Reader");
display.setCursor(0, y + 16);
display.print("Q: Back to browser");
#endif
} else {
display.setColor(DisplayDriver::YELLOW);
display.print("Download Failed");
display.drawRect(0, 11, display.width(), 1);
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, 18);
display.print(_fetchError.c_str());
display.setCursor(0, 36);
display.print(_downloadedFile);
display.setCursor(0, 56);
display.setColor(DisplayDriver::GREEN);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Tap: Back to browser");
#else
display.print("Q: Back to browser");
#endif
}
// Footer
display.setTextSize(1);
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setCursor(0, footerY);
display.setColor(DisplayDriver::YELLOW);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print(_downloadOk ? "Tap: Open in Reader" : "Tap: Back");
#else
display.print(_downloadOk ? "Ent:Read Q:Back" : "Q:Back");
#endif
}
void renderReading(DisplayDriver& display) {
if (!_textBuffer || _textLen == 0) {
display.setCursor(0, 20);
display.setColor(DisplayDriver::LIGHT);
display.print("No content");
return;
}
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
// Determine page bounds
int pageStart = (_currentPage < (int)_pageOffsets.size()) ?
_pageOffsets[_currentPage] : 0;
int pageEnd = (_currentPage + 1 < (int)_pageOffsets.size()) ?
_pageOffsets[_currentPage + 1] : _textLen;
int y = 0;
int lineCount = 0;
int pos = pageStart;
int maxY = display.height() - _footerHeight - _lineHeight;
while (pos < pageEnd && lineCount < _linesPerPage && y <= maxY) {
int oldPos = pos;
WebWrapResult wrap = webFindLineBreak(_textBuffer, pageEnd, pos, _charsPerLine);
if (wrap.nextStart <= oldPos && wrap.lineEnd >= pageEnd) break;
display.setCursor(0, y);
// Render characters with UTF-8/CP437 handling
char charStr[2] = {0, 0};
int j = pos;
while (j < wrap.lineEnd && j < pageEnd) {
uint8_t b = (uint8_t)_textBuffer[j];
if (b < 32) { j++; continue; }
// Detect link markers [N] and render in different color
if (b == '[' && j + 1 < pageEnd) {
// Check if this is a link number [N] or [NN]
int k = j + 1;
bool isNum = true;
while (k < pageEnd && _textBuffer[k] >= '0' && _textBuffer[k] <= '9') k++;
if (k > j + 1 && k < pageEnd && _textBuffer[k] == ']') {
// It's a link marker - render in highlight color
display.setColor(DisplayDriver::GREEN);
while (j <= k) {
charStr[0] = _textBuffer[j++];
display.print(charStr);
}
display.setColor(DisplayDriver::LIGHT);
continue;
}
// Check for form hint [f: ...]
if (_textBuffer[j+1] == 'f' && j + 2 < pageEnd && _textBuffer[j+2] == ':') {
display.setColor(DisplayDriver::GREEN);
while (j < pageEnd && _textBuffer[j] != ']') {
charStr[0] = _textBuffer[j++];
display.print(charStr);
}
if (j < pageEnd) { charStr[0] = ']'; display.print(charStr); j++; }
display.setColor(DisplayDriver::LIGHT);
continue;
}
}
// Detect form markers {FN} and render highlighted
if (b == '{' && j + 1 < pageEnd && _textBuffer[j+1] == 'F') {
int k = j + 2;
while (k < pageEnd && _textBuffer[k] >= '0' && _textBuffer[k] <= '9') k++;
if (k > j + 2 && k < pageEnd && _textBuffer[k] == '}') {
display.setColor(DisplayDriver::YELLOW);
while (j <= k) {
charStr[0] = _textBuffer[j++];
display.print(charStr);
}
display.setColor(DisplayDriver::LIGHT);
continue;
}
}
if (b < 0x80) {
charStr[0] = (char)b;
display.print(charStr);
j++;
} else if (b >= 0xC0) {
uint32_t cp = decodeUtf8Char(_textBuffer, wrap.lineEnd, &j);
uint8_t glyph = unicodeToCP437(cp);
if (glyph) {
charStr[0] = (char)glyph;
display.print(charStr);
}
} else {
charStr[0] = (char)b;
display.print(charStr);
j++;
}
}
y += _lineHeight;
lineCount++;
pos = wrap.nextStart;
if (pos >= pageEnd) break;
}
// Footer
display.setTextSize(1);
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
// Page counter on left
char pageBuf[20];
snprintf(pageBuf, sizeof(pageBuf), "%d/%d", _currentPage + 1, _totalPages);
display.setCursor(0, footerY);
display.print(pageBuf);
// Navigation hint on right (compact to fit setTextSize 1)
const char* hint;
char linkBuf[32];
if (_linkInputActive) {
snprintf(linkBuf, sizeof(linkBuf), "#%d_ Ent:Go", _linkInput);
hint = linkBuf;
#if defined(LilyGo_T5S3_EPaper_Pro)
} else if (_linkCount > 0) {
hint = "Tap: Page | Tap Footer Bar: Enter Link # | Hold: Back";
} else {
hint = "Tap: Page Hold: Back";
}
#else
} else if (_formCount > 0 && _linkCount > 0) {
hint = "L:Lnk F:Frm B:Bk Q:X";
} else if (_formCount > 0) {
hint = "F:Frm B:Bk Q:X";
} else if (_linkCount > 0) {
hint = "L:Lnk B:Bk Q:X";
} else {
hint = "B:Bk Q:X";
}
#endif
display.setCursor(display.width() - display.getTextWidth(hint) - 2, footerY);
display.print(hint);
// Toast notification overlay
if (_toastMsg[0] && (millis() - _toastTime < 1500)) {
display.setTextSize(1);
int tw = display.getTextWidth(_toastMsg);
int bw = tw + 16;
int bh = 20;
int bx = (display.width() - bw) / 2;
int by = (display.height() - bh) / 2;
display.setColor(DisplayDriver::DARK);
display.fillRect(bx, by, bw, bh);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(bx, by, bw, 1);
display.drawRect(bx, by + bh - 1, bw, 1);
display.drawRect(bx, by, 1, bh);
display.drawRect(bx + bw - 1, by, 1, bh);
display.setCursor(bx + 8, by + 5);
display.print(_toastMsg);
} else if (_toastMsg[0]) {
_toastMsg[0] = '\0'; // Clear expired toast
}
}
// ---- Layout Initialization ----
void initLayout(DisplayDriver& display) {
// Re-init if font preference changed since last layout
uint8_t curFont = _prefs ? _prefs->large_font : 0;
if (_initialized && curFont != _lastFontPref) {
_initialized = false;
Serial.println("WebReader: font changed, recalculating layout");
}
if (_initialized) return;
_lastFontPref = curFont;
display.setTextSize(_prefs->smallTextSize());
uint16_t mWidth = display.getTextWidth("M");
if (mWidth > 0) {
_charsPerLine = display.width() / mWidth;
_lineHeight = max(3, (int)((mWidth * 7 * 12) / (6 * 10)));
} else {
_charsPerLine = 40;
_lineHeight = 5;
}
// Proportional font: use average-width measurement instead of M-width
if (_prefs && _prefs->large_font && mWidth > 0) {
const char* sample = "the quick brown fox jumps over lazy dog";
uint16_t sampleW = display.getTextWidth(sample);
int sampleLen = strlen(sample);
if (sampleW > 0 && sampleLen > 0) {
_charsPerLine = (display.width() * sampleLen * 70) / ((int)sampleW * 100);
}
}
// Large font: formula above assumes built-in 6x8 ratio — too small for 9pt
if (_prefs && _prefs->large_font) {
_lineHeight = _prefs->smallLineH();
}
_footerHeight = 14;
int textAreaHeight = display.height() - _footerHeight;
_linesPerPage = textAreaHeight / _lineHeight;
if (_linesPerPage < 5) _linesPerPage = 5;
if (_linesPerPage > 40) _linesPerPage = 40;
display.setTextSize(1);
_initialized = true;
Serial.printf("WebReader layout: %d chars/line, %d lines/page, lineH=%d\n",
_charsPerLine, _linesPerPage, _lineHeight);
}
// ---- Input Handlers ----
bool handleWifiInput(char c) {
if (_wifiState == WIFI_SCAN_DONE) {
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_selectedSSID > 0) _selectedSSID--;
return true;
}
if (c == 's' || c == 'S' || c == 0xF1) {
if (_selectedSSID < _ssidCount - 1) _selectedSSID++;
return true;
}
if (c == '\r' || c == 13) {
_wifiState = WIFI_ENTERING_PASS;
_wifiPassLen = 0;
_wifiPass[0] = '\0';
return true;
}
} else if (_wifiState == WIFI_ENTERING_PASS) {
// Text entry for password
if (c == '\r' || c == 13) {
// Connect
connectToSSID(_ssidList[_selectedSSID], _wifiPass);
return true;
}
if (c == '\b' || c == 127) {
if (_wifiPassLen > 0) {
_wifiPass[--_wifiPassLen] = '\0';
_formLastCharAt = 0; // Clear reveal on delete
}
return true;
}
if (c >= 32 && c < 127 && _wifiPassLen < WEB_WIFI_PASS_LEN - 1) {
_wifiPass[_wifiPassLen++] = c;
_wifiPass[_wifiPassLen] = '\0';
_formLastCharAt = millis(); // Brief reveal timer
return true;
}
} else if (_wifiState == WIFI_FAILED) {
if (c == '\r' || c == 13) {
startWifiScan();
return true;
}
} else if (_wifiState == WIFI_CONNECTED) {
// Any key goes to home
_mode = HOME;
return true;
}
// Q - back to home (if possible) or exit
if (c == 'q' || c == 'Q') {
if (_wifiState == WIFI_ENTERING_PASS) {
_wifiState = WIFI_SCAN_DONE;
} else {
_mode = HOME;
}
return true;
}
return false;
}
bool handleHomeInput(char c) {
int totalItems = 3 + _bookmarks.size() + _history.size(); // IRC + URL + Search + bookmarks + history
if (_urlEditing) {
// URL text entry mode
if (c == '\r' || c == 13) {
if (_urlLen > 0) {
// Auto-add https:// if no scheme
if (strncmp(_urlBuffer, "http://", 7) != 0 &&
strncmp(_urlBuffer, "https://", 8) != 0) {
char tmp[WEB_MAX_URL_LEN];
snprintf(tmp, WEB_MAX_URL_LEN, "https://%s", _urlBuffer);
strncpy(_urlBuffer, tmp, WEB_MAX_URL_LEN - 1);
_urlLen = strlen(_urlBuffer);
}
// Encode spaces as %20
encodeUrlSpaces(_urlBuffer, WEB_MAX_URL_LEN);
_urlLen = strlen(_urlBuffer);
_urlEditing = false;
if (!isNetworkAvailable()) {
_mode = WIFI_SETUP;
if (!loadAndAutoConnect()) {
startWifiScan();
} else {
fetchWithSelfRef(_urlBuffer);
}
} else {
fetchWithSelfRef(_urlBuffer);
}
}
return true;
}
if (c == '\b' || c == 127) {
if (_urlLen > 0) {
_urlBuffer[--_urlLen] = '\0';
}
return true;
}
if (c == 'q' && _urlLen == 0) {
// Q exits URL editing when empty
_urlEditing = false;
return true;
}
// Escape URL editing mode
if (c == 0x1B) { // ESC
_urlEditing = false;
return true;
}
if (c >= 32 && c < 127 && _urlLen < WEB_MAX_URL_LEN - 1) {
_urlBuffer[_urlLen++] = c;
_urlBuffer[_urlLen] = '\0';
return true;
}
return true; // Consume all keys in editing mode
}
// Search text entry mode
if (_searchEditing) {
if (c == '\r' || c == 13) {
if (_searchLen > 0) {
_searchEditing = false;
// Build DuckDuckGo Lite search URL
// URL-encode the query: spaces become +, special chars become %XX
char encoded[256];
int ei = 0;
for (int i = 0; i < _searchLen && ei < (int)sizeof(encoded) - 4; i++) {
char ch = _searchBuffer[i];
if (ch == ' ') {
encoded[ei++] = '+';
} else if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') ||
(ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' || ch == '~') {
encoded[ei++] = ch;
} else {
if (ei < (int)sizeof(encoded) - 4) {
snprintf(encoded + ei, 4, "%%%02X", (unsigned char)ch);
ei += 3;
}
}
}
encoded[ei] = '\0';
snprintf(_urlBuffer, WEB_MAX_URL_LEN, "https://html.duckduckgo.com/lite/?q=%s", encoded);
_urlLen = strlen(_urlBuffer);
if (!isNetworkAvailable()) {
_mode = WIFI_SETUP;
if (!loadAndAutoConnect()) {
startWifiScan();
} else {
fetchWithSelfRef(_urlBuffer);
}
} else {
fetchWithSelfRef(_urlBuffer);
}
}
return true;
}
if (c == '\b' || c == 127) {
if (_searchLen > 0) {
_searchBuffer[--_searchLen] = '\0';
}
return true;
}
if (c == 'q' && _searchLen == 0) {
_searchEditing = false;
return true;
}
if (c == 0x1B) { // ESC
_searchEditing = false;
return true;
}
if (c >= 32 && c < 127 && _searchLen < (int)sizeof(_searchBuffer) - 1) {
_searchBuffer[_searchLen++] = c;
_searchBuffer[_searchLen] = '\0';
return true;
}
return true; // Consume all keys in search editing mode
}
// Normal navigation
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_homeSelected > 0) _homeSelected--;
return true;
}
if (c == 's' || c == 'S' || c == 0xF1) {
if (_homeSelected < totalItems - 1) _homeSelected++;
return true;
}
if (c == '\r' || c == 13) {
if (_homeSelected == 0) {
// IRC Chat selected
if (_ircConnected) {
// Already connected — go straight to chat
_mode = IRC_CHAT;
_ircComposing = false;
_ircScrollPos = 0;
} else {
// Open IRC setup
_mode = IRC_SETUP;
_ircSetupField = 0;
_ircSetupEditing = false;
}
return true;
}
if (_homeSelected == 1) {
// Activate URL editing
_urlEditing = true;
return true;
}
if (_homeSelected == 2) {
// Activate search editing
_searchEditing = true;
return true;
}
// Bookmark or history item selected (offset by 3 for IRC + URL + Search)
const char* selectedUrl = nullptr;
int bmIdx = _homeSelected - 3;
if (bmIdx < (int)_bookmarks.size()) {
selectedUrl = _bookmarks[bmIdx].c_str();
} else {
int histIdx = bmIdx - _bookmarks.size();
if (histIdx < (int)_history.size()) {
selectedUrl = _history[histIdx].c_str();
}
}
if (selectedUrl) {
strncpy(_urlBuffer, selectedUrl, WEB_MAX_URL_LEN - 1);
_urlLen = strlen(_urlBuffer);
if (!isNetworkAvailable()) {
_mode = WIFI_SETUP;
if (!loadAndAutoConnect()) {
startWifiScan();
} else {
fetchWithSelfRef(_urlBuffer);
}
} else {
fetchWithSelfRef(_urlBuffer);
}
}
return true;
}
// Delete/Backspace - remove selected bookmark
if (c == '\b' || c == 127) {
int bmIdx = _homeSelected - 3;
if (bmIdx >= 0 && bmIdx < (int)_bookmarks.size()) {
_bookmarks.erase(_bookmarks.begin() + bmIdx);
saveBookmarks();
// Adjust selection if we deleted the last item
int newTotal = 3 + _bookmarks.size() + _history.size();
if (_homeSelected >= newTotal && _homeSelected > 0) {
_homeSelected--;
}
strncpy(_toastMsg, "Bookmark deleted", sizeof(_toastMsg));
_toastTime = millis();
Serial.printf("WebReader: Deleted bookmark %d\n", bmIdx);
return true;
}
return false;
}
// X - clear all cookies
if (c == 'x' || c == 'X') {
bool hadData = (_cookieCount > 0 || !_history.empty());
_cookieCount = 0;
memset(_cookies, 0, sizeof(Cookie) * WEB_MAX_COOKIES);
_history.clear();
saveHistory();
Serial.println("WebReader: Cookies and history cleared");
return hadData;
}
return false;
}
bool handleReadingInput(char c) {
// Link number input mode - accumulate digits, Enter to follow
if (_linkInputActive) {
if (c >= '0' && c <= '9') {
int newVal = _linkInput * 10 + (c - '0');
if (newVal <= 999) {
_linkInput = newVal;
}
return true;
}
if (c == '\r' || c == 13) {
// Enter confirms the link
if (_linkInput > 0 && _linkInput <= _linkCount) {
_linkInputActive = false;
WebLink& link = _links[_linkInput - 1];
strncpy(_urlBuffer, link.url, WEB_MAX_URL_LEN - 1);
_urlLen = strlen(_urlBuffer);
fetchPage(_urlBuffer, nullptr, nullptr, _currentUrl);
} else {
_linkInputActive = false;
_linkInput = 0;
}
return true;
}
if (c == '\b' || c == 127) {
// Backspace removes last digit
_linkInput /= 10;
if (_linkInput == 0) {
_linkInputActive = false;
}
return true;
}
// Any other key cancels link input
_linkInputActive = false;
_linkInput = 0;
return true;
}
// W/A - previous page
if (c == 'w' || c == 'W' || c == 'a' || c == 'A' || c == 0xF2) {
if (_currentPage > 0) {
_currentPage--;
return true;
}
return false;
}
// S/D/Space - next page
if (c == 's' || c == 'S' || c == 'd' || c == 'D' || c == ' ' || c == 0xF1) {
if (_currentPage < _totalPages - 1) {
_currentPage++;
return true;
}
return false;
}
// L - enter link selection mode
if (c == 'l' || c == 'L') {
if (_linkCount > 0) {
_linkInputActive = true;
_linkInput = 0;
return true;
}
return false;
}
// G - go to URL (back to home to enter a new URL)
if (c == 'g' || c == 'G') {
_mode = HOME;
_homeSelected = 0;
return true;
}
// K - add current page to bookmarks
if (c == 'k' || c == 'K') {
if (_currentUrl[0]) {
addBookmark(_currentUrl);
strncpy(_toastMsg, "Bookmarked!", sizeof(_toastMsg));
_toastTime = millis();
return true;
}
return false;
}
// B - back to previous page
if (c == 'b' || c == 'B') {
if (!_backHistory.empty()) {
String prevUrl = _backHistory.back();
_backHistory.pop_back();
// Temporarily clear _currentUrl so fetchPage doesn't re-push it
_currentUrl[0] = '\0';
strncpy(_urlBuffer, prevUrl.c_str(), WEB_MAX_URL_LEN - 1);
_urlLen = strlen(_urlBuffer);
fetchWithSelfRef(_urlBuffer);
} else {
_mode = HOME;
_homeSelected = 0;
}
return true;
}
// Q - exit to home
if (c == 'q' || c == 'Q') {
_mode = HOME;
_homeSelected = 0;
return true;
}
// F - enter form fill mode (if page has forms)
if (c == 'f' || c == 'F') {
if (_formCount > 0) {
// If only one form, select it directly
_activeForm = 0;
_activeField = 0;
_formFieldEditing = false;
_mode = FORM_FILL;
return true;
}
return false;
}
// Enter - if links exist, enter link mode (same as L)
if ((c == '\r' || c == 13) && _linkCount > 0) {
_linkInputActive = true;
_linkInput = 0;
return true;
}
return false;
}
// Get the actual field index for the n-th visible (non-hidden) field
int getVisibleFieldIdx(const WebForm& form, int visIdx) {
int vis = 0;
for (int i = 0; i < form.fieldCount; i++) {
if (form.fields[i].type != 'h') {
if (vis == visIdx) return i;
vis++;
}
}
return -1;
}
int getVisibleFieldCount(const WebForm& form) {
int count = 0;
for (int i = 0; i < form.fieldCount; i++) {
if (form.fields[i].type != 'h') count++;
}
return count;
}
void renderFormFill(DisplayDriver& display) {
if (_activeForm < 0 || _activeForm >= _formCount) return;
WebForm& form = _forms[_activeForm];
display.setTextSize(_prefs->smallTextSize());
// Header
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
// Show form number if multiple forms
if (_formCount > 1) {
char hdr[40];
snprintf(hdr, sizeof(hdr), "Form %d/%d", _activeForm + 1, _formCount);
display.print(hdr);
} else {
display.print("Form");
}
// Show form action domain on right
char actionDomain[32];
extractDomain(form.action, actionDomain, sizeof(actionDomain));
display.setCursor(display.width() - display.getTextWidth(actionDomain) - 2, 0);
display.print(actionDomain);
display.drawRect(0, 9, display.width(), 1);
int y = 12;
int lineH = _prefs->smallLineH() + 1; // Taller lines for form fields
int visCount = getVisibleFieldCount(form);
// Render each visible field
int visIdx = 0;
for (int i = 0; i < form.fieldCount && y < display.height() - 24; i++) {
WebFormField& fld = form.fields[i];
if (fld.type == 'h') continue; // Skip hidden fields
bool isActive = (visIdx == _activeField);
// Label
display.setColor(isActive ? DisplayDriver::GREEN : DisplayDriver::YELLOW);
display.setCursor(0, y);
display.print(fld.label);
y += 8;
// Field value
if (isActive) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), 9);
#else
display.fillRect(0, y + 4, display.width(), 9);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(2, y);
if (isActive && _formFieldEditing) {
// Show edit buffer with cursor
if (fld.type == 'p') {
// Password - show last char briefly (800ms), rest as dots
bool revealing = (_formEditLen > 0 && (millis() - _formLastCharAt) < 800);
char masked[WEB_MAX_FIELD_VALUE + 2];
for (int m = 0; m < _formEditLen; m++) {
if (m == _formEditLen - 1 && revealing)
masked[m] = _formEditBuf[m]; // Show last char
else
masked[m] = '*';
}
masked[_formEditLen] = '_'; // Cursor
masked[_formEditLen + 1] = '\0';
display.print(masked);
} else {
// Show text with cursor
int maxShow = _charsPerLine - 2;
int start = 0;
if (_formEditLen > maxShow) start = _formEditLen - maxShow;
char disp[WEB_MAX_FIELD_VALUE + 2];
snprintf(disp, sizeof(disp), "%s_", _formEditBuf + start);
display.print(disp);
}
} else if (fld.type == 's') {
// Submit button
display.setColor(isActive ? DisplayDriver::DARK : DisplayDriver::GREEN);
char btn[60];
snprintf(btn, sizeof(btn), "[ %s ]", fld.value[0] ? fld.value : "Submit");
display.print(btn);
} else if (fld.type == 'c') {
// Checkbox
display.print(fld.value[0] && strcmp(fld.value, "0") != 0 ? "[X]" : "[ ]");
} else {
// Text/password display (not editing)
if (fld.type == 'p' && fld.value[0]) {
String masked;
int len = strlen(fld.value);
for (int m = 0; m < len; m++) masked += '*';
display.print(masked.c_str());
} else if (fld.value[0]) {
display.print(fld.value);
} else {
display.setColor(isActive ? DisplayDriver::DARK : DisplayDriver::YELLOW);
display.print("(empty)");
}
}
y += lineH;
visIdx++;
}
// Footer
display.setTextSize(1);
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
if (_formFieldEditing) {
display.print("Type text Ent:Next Q:Undo");
} else {
const char* hint;
#if defined(LilyGo_T5S3_EPaper_Pro)
hint = "Swipe: Navigate Tap: Edit Hold: Back";
#else
if (_formCount > 1)
hint = "W/S:Nav Ent:Edit </>:Form Q:Back";
else
hint = "W/S:Nav Ent:Edit/Go Q:Back";
#endif
display.print(hint);
}
}
bool handleFormFillInput(char c) {
if (_activeForm < 0 || _activeForm >= _formCount) {
_mode = READING;
return true;
}
WebForm& form = _forms[_activeForm];
int visCount = getVisibleFieldCount(form);
if (visCount == 0) {
_mode = READING;
return true;
}
// --- Field editing mode ---
if (_formFieldEditing) {
int realIdx = getVisibleFieldIdx(form, _activeField);
if (realIdx < 0) { _formFieldEditing = false; return true; }
WebFormField& fld = form.fields[realIdx];
// Enter - save field and advance to next field
if (c == '\r' || c == 13) {
memcpy(fld.value, _formEditBuf, _formEditLen);
fld.value[_formEditLen] = '\0';
_formFieldEditing = false;
_formLastCharAt = 0;
// Auto-advance to next field
if (_activeField < visCount - 1) _activeField++;
return true;
}
// Backspace
if (c == 8 || c == 127 || c == 0xF3) {
if (_formEditLen > 0) _formEditLen--;
_formEditBuf[_formEditLen] = '\0';
_formLastCharAt = 0; // No reveal after delete
return true;
}
// Q as cancel — discard edits, restore original value
if ((c == 'q' || c == 'Q') && _formEditLen == 0) {
_formFieldEditing = false;
_formLastCharAt = 0;
return true;
}
// Printable character
if (c >= 32 && c < 127 && _formEditLen < WEB_MAX_FIELD_VALUE - 1) {
_formEditBuf[_formEditLen++] = c;
_formEditBuf[_formEditLen] = '\0';
_formLastCharAt = millis(); // Start brief reveal
return true;
}
return false;
}
// --- Navigation mode ---
// W/Up - previous field
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_activeField > 0) _activeField--;
return true;
}
// S/Down - next field
if (c == 's' || c == 'S' || c == 0xF1) {
if (_activeField < visCount - 1) _activeField++;
return true;
}
// Enter - edit current field or submit
if (c == '\r' || c == 13) {
int realIdx = getVisibleFieldIdx(form, _activeField);
if (realIdx < 0) return false;
WebFormField& fld = form.fields[realIdx];
if (fld.type == 's') {
// Submit button - submit the form
submitForm(_activeForm);
return true;
} else if (fld.type == 'c') {
// Toggle checkbox
if (fld.value[0] && strcmp(fld.value, "0") != 0)
strcpy(fld.value, "0");
else
strcpy(fld.value, "1");
return true;
} else {
// Text/password - enter editing mode
_formFieldEditing = true;
_formEditLen = strlen(fld.value);
memcpy(_formEditBuf, fld.value, _formEditLen);
_formEditBuf[_formEditLen] = '\0';
return true;
}
}
// < > or , . to switch between forms (when multiple)
if ((c == ',' || c == '<') && _formCount > 1) {
if (_activeForm > 0) {
_activeForm--;
_activeField = 0;
_formFieldEditing = false;
}
return true;
}
if ((c == '.' || c == '>') && _formCount > 1) {
if (_activeForm < _formCount - 1) {
_activeForm++;
_activeField = 0;
_formFieldEditing = false;
}
return true;
}
// Q - back to reading
if (c == 'q' || c == 'Q') {
_mode = READING;
_formFieldEditing = false;
return true;
}
return false;
}
// ==========================================================================
// IRC Client Implementation
// ==========================================================================
void loadIRCConfig() {
// Default values
strncpy(_ircHost, "irc.eastmesh.au", IRC_MAX_HOST_LEN);
_ircPort = 6697;
strncpy(_ircNick, "meck-user", IRC_MAX_NICK_LEN);
_ircChannel[0] = '\0'; // No default channel — user must configure
File f = SD.open(IRC_CONFIG_FILE, FILE_READ);
if (!f) { digitalWrite(SDCARD_CS, HIGH); return; }
String host = f.readStringUntil('\n'); host.trim();
String port = f.readStringUntil('\n'); port.trim();
String nick = f.readStringUntil('\n'); nick.trim();
String chan = f.readStringUntil('\n'); chan.trim();
f.close();
digitalWrite(SDCARD_CS, HIGH);
if (host.length() > 0) strncpy(_ircHost, host.c_str(), IRC_MAX_HOST_LEN - 1);
if (port.length() > 0) _ircPort = port.toInt();
if (nick.length() > 0) strncpy(_ircNick, nick.c_str(), IRC_MAX_NICK_LEN - 1);
if (chan.length() > 0) strncpy(_ircChannel, chan.c_str(), IRC_MAX_CHANNEL_LEN - 1);
Serial.printf("IRC: Config loaded - %s:%d nick=%s chan=%s\n",
_ircHost, _ircPort, _ircNick, _ircChannel);
}
void saveIRCConfig() {
if (!SD.exists(WEB_CACHE_DIR)) SD.mkdir(WEB_CACHE_DIR);
File f = SD.open(IRC_CONFIG_FILE, FILE_WRITE);
if (f) {
f.println(_ircHost);
f.println(_ircPort);
f.println(_ircNick);
f.println(_ircChannel);
f.close();
}
digitalWrite(SDCARD_CS, HIGH);
}
bool allocateIRCBuffers() {
if (!_ircMessages) {
_ircMessages = (IRCMessage*)ps_calloc(IRC_MAX_MESSAGES, sizeof(IRCMessage));
if (!_ircMessages) {
Serial.println("IRC: Failed to allocate message buffer");
return false;
}
}
return true;
}
void addIRCMessage(const char* nick, const char* text, bool isSystem = false) {
if (!_ircMessages) return;
IRCMessage& msg = _ircMessages[_ircMsgHead];
strncpy(msg.nick, nick, IRC_MAX_NICK_LEN - 1);
msg.nick[IRC_MAX_NICK_LEN - 1] = '\0';
// Convert UTF-8 text to CP437 for proper e-ink rendering
int srcLen = strlen(text);
int dstIdx = 0;
int srcPos = 0;
while (srcPos < srcLen && dstIdx < IRC_MAX_MSG_LEN - 1) {
uint32_t cp = decodeUtf8Char(text, srcLen, &srcPos);
if (cp == 0 || cp == 0xFFFD) continue; // Skip invalid
if (cp < 128) {
msg.text[dstIdx++] = (char)cp;
} else {
uint8_t glyph = unicodeToCP437(cp);
msg.text[dstIdx++] = glyph ? (char)glyph : '?';
}
}
msg.text[dstIdx] = '\0';
msg.isSystem = isSystem;
_ircMsgHead = (_ircMsgHead + 1) % IRC_MAX_MESSAGES;
if (_ircMsgCount < IRC_MAX_MESSAGES) _ircMsgCount++;
_ircDirty = true;
}
// Get message by index from oldest (0) to newest (count-1)
IRCMessage* getIRCMessage(int idx) {
if (!_ircMessages || idx < 0 || idx >= _ircMsgCount) return nullptr;
int start = (_ircMsgHead - _ircMsgCount + IRC_MAX_MESSAGES) % IRC_MAX_MESSAGES;
int actual = (start + idx) % IRC_MAX_MESSAGES;
return &_ircMessages[actual];
}
void ircSendRaw(const char* line) {
if (!_ircClient || !_ircClient->connected()) return;
_ircClient->print(line);
_ircClient->print("\r\n");
Serial.printf("IRC TX: %s\n", line);
}
bool connectIRC() {
if (!allocateIRCBuffers()) return false;
addIRCMessage("*", "Connecting...", true);
if (_ircClient) {
_ircClient->stop();
delete _ircClient;
}
_ircUseTLS = (_ircPort == 6697);
if (_ircUseTLS) {
// Redirect mbedTLS allocations to PSRAM — internal heap doesn't have
// enough contiguous space (~32-48KB) for TLS handshake buffers.
ensureTlsUsesPsram();
auto* secClient = new WiFiClientSecure();
secClient->setInsecure(); // Accept any cert (for self-signed IRC servers)
_ircClient = secClient;
Serial.printf("IRC: Connecting to %s:%d (TLS)...\n", _ircHost, _ircPort);
} else {
_ircClient = new WiFiClient();
Serial.printf("IRC: Connecting to %s:%d (plain)...\n", _ircHost, _ircPort);
}
if (!_ircClient->connect(_ircHost, _ircPort, 10000)) {
Serial.println("IRC: Connection failed");
addIRCMessage("*", "Connection failed!", true);
delete _ircClient;
_ircClient = nullptr;
_ircConnected = false;
return false;
}
_ircConnected = true;
_ircRegistered = false;
_ircJoined = false;
_ircLineLen = 0;
_ircLastDataTime = millis();
// Send registration
char buf[256];
snprintf(buf, sizeof(buf), "NICK %s", _ircNick);
ircSendRaw(buf);
snprintf(buf, sizeof(buf), "USER %s 0 * :Meck T-Deck Pro", _ircNick);
ircSendRaw(buf);
addIRCMessage("*", "Registering...", true);
return true;
}
void disconnectIRC() {
if (_ircClient) {
if (_ircClient->connected()) {
ircSendRaw("QUIT :Meck signing off");
}
_ircClient->stop();
delete _ircClient;
_ircClient = nullptr;
}
_ircConnected = false;
_ircRegistered = false;
_ircJoined = false;
addIRCMessage("*", "Disconnected", true);
}
void parseIRCLine(const char* line) {
Serial.printf("IRC RX: %s\n", line);
_ircLastDataTime = millis();
// PING :xxx → respond with PONG :xxx
if (strncmp(line, "PING ", 5) == 0) {
char pong[IRC_LINE_BUF_SIZE];
snprintf(pong, sizeof(pong), "PONG %s", line + 5);
ircSendRaw(pong);
return;
}
// Lines starting with : are server/user messages
if (line[0] != ':') return;
// Parse prefix and command
const char* p = line + 1;
const char* prefixEnd = strchr(p, ' ');
if (!prefixEnd) return;
// Extract nick from prefix (nick!user@host)
char senderNick[IRC_MAX_NICK_LEN] = {0};
{
int nLen = 0;
const char* excl = strchr(p, '!');
const char* end = excl ? excl : prefixEnd;
int maxLen = end - p;
if (maxLen > IRC_MAX_NICK_LEN - 1) maxLen = IRC_MAX_NICK_LEN - 1;
memcpy(senderNick, p, maxLen);
senderNick[maxLen] = '\0';
}
// Skip to command
const char* cmd = prefixEnd + 1;
while (*cmd == ' ') cmd++;
// Check numeric replies
if (cmd[0] >= '0' && cmd[0] <= '9' && cmd[1] >= '0' && cmd[1] <= '9') {
int numeric = atoi(cmd);
if (numeric == 1) {
// RPL_WELCOME - registration complete
_ircRegistered = true;
// Only join if a channel is configured
if (_ircChannel[0] == '\0') {
addIRCMessage("*", "Registered! Use /join #channel", true);
return;
}
// Auto-prefix with # if missing
if (_ircChannel[0] != '#' && _ircChannel[0] != '&' && _ircChannel[0] != '+') {
char tmp[IRC_MAX_CHANNEL_LEN];
snprintf(tmp, sizeof(tmp), "#%s", _ircChannel);
strncpy(_ircChannel, tmp, IRC_MAX_CHANNEL_LEN - 1);
}
char statusBuf[128];
snprintf(statusBuf, sizeof(statusBuf), "Registered! Joining %s...", _ircChannel);
addIRCMessage("*", statusBuf, true);
char join[128];
snprintf(join, sizeof(join), "JOIN %s", _ircChannel);
ircSendRaw(join);
return;
}
if (numeric == 433) {
// Nick already in use — cycle last character (respects server NICKLEN)
int nLen = strlen(_ircNick);
if (nLen > 0) {
char last = _ircNick[nLen - 1];
if (last >= '0' && last < '9') {
_ircNick[nLen - 1] = last + 1; // meck-user1 -> meck-user2
} else if (last == '9') {
_ircNick[nLen - 1] = '0'; // wrap around
} else {
_ircNick[nLen - 1] = '1'; // meck-user -> meck-use1
}
char buf[128];
snprintf(buf, sizeof(buf), "NICK %s", _ircNick);
ircSendRaw(buf);
char msgBuf[64];
snprintf(msgBuf, sizeof(msgBuf), "Nick taken, trying %s", _ircNick);
addIRCMessage("*", msgBuf, true);
}
return;
}
// MOTD and other informational numerics - show select ones
if (numeric == 372 || numeric == 375 || numeric == 376) {
// MOTD lines - extract text after the last :
const char* text = strrchr(cmd, ':');
if (text) {
addIRCMessage("*", text + 1, true);
}
return;
}
// Topic (332) and names (353) - show as system messages
if (numeric == 332 || numeric == 353) {
const char* text = strrchr(cmd, ':');
if (text) {
addIRCMessage("*", text + 1, true);
}
return;
}
// Error numerics (400-599) - show as system messages
if (numeric >= 400 && numeric < 600) {
const char* text = strrchr(cmd, ':');
if (text) {
char errBuf[IRC_MAX_MSG_LEN];
snprintf(errBuf, sizeof(errBuf), "Error %d: %s", numeric, text + 1);
addIRCMessage("*", errBuf, true);
}
return;
}
return; // Skip other numerics
}
// PRIVMSG #channel :message
if (strncmp(cmd, "PRIVMSG ", 8) == 0) {
const char* target = cmd + 8;
const char* msgText = strchr(target, ':');
if (msgText) {
msgText++; // skip the :
// Check for CTCP ACTION (\001ACTION text\001)
if (strncmp(msgText, "\001ACTION ", 8) == 0) {
char actionBuf[IRC_MAX_MSG_LEN];
const char* actionEnd = strchr(msgText + 8, '\001');
int aLen = actionEnd ? (actionEnd - msgText - 8) : strlen(msgText + 8);
if (aLen > IRC_MAX_MSG_LEN - 4) aLen = IRC_MAX_MSG_LEN - 4;
snprintf(actionBuf, sizeof(actionBuf), "* %.*s", aLen, msgText + 8);
addIRCMessage(senderNick, actionBuf);
} else {
addIRCMessage(senderNick, msgText);
}
}
return;
}
// JOIN
if (strncmp(cmd, "JOIN", 4) == 0) {
if (strcmp(senderNick, _ircNick) == 0) {
_ircJoined = true;
char buf[128];
snprintf(buf, sizeof(buf), "Joined %s", _ircChannel);
addIRCMessage("*", buf, true);
} else {
char buf[128];
snprintf(buf, sizeof(buf), "%s joined", senderNick);
addIRCMessage("*", buf, true);
}
return;
}
// PART
if (strncmp(cmd, "PART", 4) == 0) {
char buf[128];
snprintf(buf, sizeof(buf), "%s left", senderNick);
addIRCMessage("*", buf, true);
return;
}
// QUIT
if (strncmp(cmd, "QUIT", 4) == 0) {
char buf[128];
const char* reason = strchr(cmd + 5, ':');
if (reason) {
snprintf(buf, sizeof(buf), "%s quit (%s)", senderNick, reason + 1);
} else {
snprintf(buf, sizeof(buf), "%s quit", senderNick);
}
addIRCMessage("*", buf, true);
return;
}
// NICK change
if (strncmp(cmd, "NICK", 4) == 0) {
const char* newNick = strchr(cmd + 5, ':');
if (!newNick) newNick = cmd + 5;
else newNick++;
char buf[128];
snprintf(buf, sizeof(buf), "%s is now %s", senderNick, newNick);
addIRCMessage("*", buf, true);
// Update our own nick if it was us
if (strcmp(senderNick, _ircNick) == 0) {
strncpy(_ircNick, newNick, IRC_MAX_NICK_LEN - 1);
}
return;
}
}
void pollIRC() {
if (!_ircClient || !_ircConnected) {
// Check for reconnect
if (_ircReconnectAt > 0 && millis() > _ircReconnectAt) {
_ircReconnectAt = 0;
connectIRC();
}
return;
}
if (!_ircClient->connected()) {
Serial.println("IRC: Connection lost");
addIRCMessage("*", "Connection lost. Reconnecting...", true);
_ircConnected = false;
_ircRegistered = false;
_ircJoined = false;
delete _ircClient;
_ircClient = nullptr;
_ircReconnectAt = millis() + IRC_RECONNECT_MS;
return;
}
// Check for ping timeout
if (millis() - _ircLastDataTime > IRC_PING_TIMEOUT_MS) {
Serial.println("IRC: Ping timeout");
addIRCMessage("*", "Ping timeout. Reconnecting...", true);
disconnectIRC();
_ircReconnectAt = millis() + IRC_RECONNECT_MS;
return;
}
// Read available data and accumulate lines
while (_ircClient->available()) {
char c = _ircClient->read();
if (c == '\r') continue; // skip CR
if (c == '\n') {
// Complete line received
_ircLineBuf[_ircLineLen] = '\0';
if (_ircLineLen > 0) {
parseIRCLine(_ircLineBuf);
}
_ircLineLen = 0;
continue;
}
if (_ircLineLen < IRC_LINE_BUF_SIZE - 1) {
_ircLineBuf[_ircLineLen++] = c;
}
}
}
void sendIRCMessage() {
if (!_ircConnected || _ircComposeLen == 0) return;
_ircCompose[_ircComposeLen] = '\0';
// Check for /commands (allowed even before joining a channel)
if (_ircCompose[0] == '/') {
if (strncmp(_ircCompose, "/me ", 4) == 0) {
if (!_ircJoined) { addIRCMessage("*", "Not in a channel", true); }
else {
char buf[IRC_LINE_BUF_SIZE];
snprintf(buf, sizeof(buf), "PRIVMSG %s :\001ACTION %s\001",
_ircChannel, _ircCompose + 4);
ircSendRaw(buf);
char dispBuf[IRC_MAX_MSG_LEN];
snprintf(dispBuf, sizeof(dispBuf), "* %s", _ircCompose + 4);
addIRCMessage(_ircNick, dispBuf);
}
} else if (strncmp(_ircCompose, "/nick ", 6) == 0) {
char buf[128];
snprintf(buf, sizeof(buf), "NICK %s", _ircCompose + 6);
ircSendRaw(buf);
} else if (strncmp(_ircCompose, "/join ", 6) == 0) {
const char* chan = _ircCompose + 6;
// Auto-prefix with # if missing
if (chan[0] != '#' && chan[0] != '&' && chan[0] != '+') {
char tmp[IRC_MAX_CHANNEL_LEN];
snprintf(tmp, sizeof(tmp), "#%s", chan);
strncpy(_ircChannel, tmp, IRC_MAX_CHANNEL_LEN - 1);
} else {
strncpy(_ircChannel, chan, IRC_MAX_CHANNEL_LEN - 1);
}
char buf[128];
snprintf(buf, sizeof(buf), "JOIN %s", _ircChannel);
ircSendRaw(buf);
_ircJoined = false;
} else if (strcmp(_ircCompose, "/quit") == 0) {
disconnectIRC();
_mode = HOME;
_homeSelected = 0;
} else {
// Send as raw IRC command (strip the /)
ircSendRaw(_ircCompose + 1);
}
} else {
// Normal message - requires being in a channel
if (!_ircJoined) {
addIRCMessage("*", "Not in a channel. Use /join #channel", true);
} else {
char buf[IRC_LINE_BUF_SIZE];
snprintf(buf, sizeof(buf), "PRIVMSG %s :%s", _ircChannel, _ircCompose);
ircSendRaw(buf);
addIRCMessage(_ircNick, _ircCompose);
}
}
_ircComposeLen = 0;
_ircCompose[0] = '\0';
_ircScrollPos = 0; // Snap to bottom on send
}
// ---- IRC Setup Screen ----
void renderIRCSetup(DisplayDriver& display) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.setCursor(0, 0);
display.print("IRC Setup");
display.drawRect(0, 11, display.width(), 1);
display.setTextSize(_prefs->smallTextSize());
int y = 16;
int lineH = _prefs->smallLineH() + 1;
const char* labels[] = {"Server:", "Port:", "Nick:", "Channel:", "[ Connect ]"};
const char* chanDisp = (_ircChannel[0] != '\0') ? _ircChannel : "(none)";
const char* values[] = {_ircHost, nullptr, _ircNick, chanDisp, nullptr};
char portStr[8];
snprintf(portStr, sizeof(portStr), "%d", _ircPort);
for (int i = 0; i < 5; i++) {
bool sel = (_ircSetupField == i);
if (sel) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineH);
#else
display.fillRect(0, y + 4, display.width(), lineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
display.setCursor(2, y);
if (i == 4) {
// Connect button
display.setCursor(display.width() / 2 - 30, y);
display.print(sel ? "> Connect <" : "[ Connect ]");
} else if (i == 1) {
// Port
char buf[64];
if (_ircSetupEditing && sel) {
snprintf(buf, sizeof(buf), "%s %s_", labels[i], _ircSetupBuf);
} else {
snprintf(buf, sizeof(buf), "%s %s", labels[i], portStr);
}
display.print(buf);
} else {
char buf[128];
if (_ircSetupEditing && sel) {
snprintf(buf, sizeof(buf), "%s %s_", labels[i], _ircSetupBuf);
} else {
snprintf(buf, sizeof(buf), "%s %s", labels[i], values[i]);
}
display.print(buf);
}
y += lineH;
}
// Status
y += 6;
display.setColor(DisplayDriver::YELLOW);
display.setCursor(2, y);
if (_ircConnected) {
display.print(_ircJoined ? "Connected & joined" : "Connected...");
} else {
display.print("Not connected");
}
// Footer
display.setTextSize(1);
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setCursor(0, footerY);
display.setColor(DisplayDriver::YELLOW);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Swipe: Navigate Tap: Edit Hold: Back");
#else
display.print("W/S:Nav Ent:Edit/Go Q:Back");
#endif
}
bool handleIRCSetupInput(char c) {
if (_ircSetupEditing) {
if (c == '\r' || c == 13) {
// Save the field
_ircSetupBuf[_ircSetupBufLen] = '\0';
switch (_ircSetupField) {
case 0: strncpy(_ircHost, _ircSetupBuf, IRC_MAX_HOST_LEN - 1); break;
case 1: _ircPort = atoi(_ircSetupBuf); if (_ircPort == 0) _ircPort = 6697; break;
case 2: strncpy(_ircNick, _ircSetupBuf, IRC_MAX_NICK_LEN - 1); break;
case 3: strncpy(_ircChannel, _ircSetupBuf, IRC_MAX_CHANNEL_LEN - 1); break;
}
_ircSetupEditing = false;
return true;
}
if (c == '\b' || c == 127) {
if (_ircSetupBufLen > 0) _ircSetupBuf[--_ircSetupBufLen] = '\0';
return true;
}
if (c == 0x1B) { _ircSetupEditing = false; return true; }
if (c >= 32 && c < 127 && _ircSetupBufLen < (int)sizeof(_ircSetupBuf) - 1) {
_ircSetupBuf[_ircSetupBufLen++] = c;
_ircSetupBuf[_ircSetupBufLen] = '\0';
return true;
}
return true;
}
// Navigation
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_ircSetupField > 0) _ircSetupField--;
return true;
}
if (c == 's' || c == 'S' || c == 0xF1) {
if (_ircSetupField < 4) _ircSetupField++;
return true;
}
if (c == '\r' || c == 13) {
if (_ircSetupField == 4) {
// Connect button
saveIRCConfig();
if (!isNetworkAvailable()) {
addIRCMessage("*", "No network! Connect WiFi first.", true);
_mode = WIFI_SETUP;
startWifiScan();
return true;
}
if (connectIRC()) {
_mode = IRC_CHAT;
_ircComposing = false;
_ircComposeLen = 0;
_ircCompose[0] = '\0';
_ircScrollPos = 0;
}
return true;
}
// Start editing the selected field
_ircSetupEditing = true;
switch (_ircSetupField) {
case 0: strncpy(_ircSetupBuf, _ircHost, sizeof(_ircSetupBuf)); break;
case 1: snprintf(_ircSetupBuf, sizeof(_ircSetupBuf), "%d", _ircPort); break;
case 2: strncpy(_ircSetupBuf, _ircNick, sizeof(_ircSetupBuf)); break;
case 3: strncpy(_ircSetupBuf, _ircChannel, sizeof(_ircSetupBuf)); break;
}
_ircSetupBufLen = strlen(_ircSetupBuf);
return true;
}
if (c == 'q' || c == 'Q') {
_mode = HOME;
_homeSelected = 0;
return true;
}
return false;
}
// ---- IRC Chat Screen ----
void renderIRCChat(DisplayDriver& display) {
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
display.setCursor(0, 0);
// Header: channel name + connection status
char header[64];
snprintf(header, sizeof(header), "%s", _ircChannel);
display.print(header);
// Connection indicator on right
display.setTextSize(_prefs->smallTextSize());
if (!_ircConnected) {
display.setColor(DisplayDriver::YELLOW);
display.setCursor(display.width() - 42, -3);
display.print("DISCONN");
} else if (!_ircJoined) {
display.setColor(DisplayDriver::YELLOW);
display.setCursor(display.width() - 36, -3);
display.print("joining");
} else {
display.setColor(DisplayDriver::GREEN);
const char* nickDisp = _ircNick;
display.setCursor(display.width() - display.getTextWidth(nickDisp) - 2, -3);
display.print(nickDisp);
}
display.setTextSize(1);
display.drawRect(0, 11, display.width(), 1);
// Footer area
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setTextSize(1);
if (_ircComposing) {
// Compose text just above separator (tiny font to match messages)
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, footerY - 12);
char compDisp[IRC_COMPOSE_MAX + 4];
int maxShow = _charsPerLine - 2;
int start = 0;
if (_ircComposeLen > maxShow) start = _ircComposeLen - maxShow;
snprintf(compDisp, sizeof(compDisp), "> %s_", _ircCompose + start);
display.print(compDisp);
// Hint on footer line (large font)
display.setTextSize(1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Tap: Send Hold: Exit");
#else
display.print("Ent:Send Del:Exit");
#endif
} else {
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Tap: Compose Swipe: Scroll Hold: Back");
#else
display.print("Ent:Msg W/S:Scrl Q:Bk");
#endif
}
// Message area
display.setTextSize(_prefs->smallTextSize());
int msgAreaTop = 14;
int msgAreaBottom = _ircComposing ? footerY - 16 : footerY - 4;
int lineH = _prefs->smallLineH() - 1;
int scrollBarW = 4;
int lineW = _charsPerLine - 1; // Reserve space for scroll bar
_ircLinesPerPage = (msgAreaBottom - msgAreaTop) / lineH;
if (_ircMsgCount == 0) {
display.setColor(DisplayDriver::LIGHT);
display.setCursor(4, msgAreaTop + 10);
display.print("No messages yet...");
// Draw empty scrollbar track
int sbX = display.width() - scrollBarW;
display.setColor(DisplayDriver::LIGHT);
display.drawRect(sbX, msgAreaTop, scrollBarW, msgAreaBottom - msgAreaTop);
return;
}
// Calculate which messages to show (from bottom, with scroll offset)
int startMsg = _ircMsgCount - _ircLinesPerPage - _ircScrollPos;
if (startMsg < 0) startMsg = 0;
int endMsg = startMsg + _ircLinesPerPage;
if (endMsg > _ircMsgCount) endMsg = _ircMsgCount;
int y = msgAreaTop;
for (int i = startMsg; i < endMsg && y < msgAreaBottom; i++) {
IRCMessage* msg = getIRCMessage(i);
if (!msg) continue;
display.setCursor(0, y);
if (msg->isSystem) {
display.setColor(DisplayDriver::YELLOW);
char line[IRC_MAX_MSG_LEN + IRC_MAX_NICK_LEN + 4];
snprintf(line, sizeof(line), "-- %s", msg->text);
// Truncate to screen width (minus scrollbar)
if ((int)strlen(line) > lineW)
line[lineW] = '\0';
display.print(line);
} else {
// Nick in green, message in light
display.setColor(DisplayDriver::GREEN);
char nickBuf[IRC_MAX_NICK_LEN + 2];
snprintf(nickBuf, sizeof(nickBuf), "%s: ", msg->nick);
display.print(nickBuf);
display.setColor(DisplayDriver::LIGHT);
int nickW = display.getTextWidth(nickBuf);
int remainChars = lineW - strlen(nickBuf);
if (remainChars > 0) {
char textBuf[IRC_MAX_MSG_LEN];
strncpy(textBuf, msg->text, remainChars);
textBuf[remainChars] = '\0';
display.print(textBuf);
// If message is longer, wrap to next line
int msgLen = strlen(msg->text);
if (msgLen > remainChars && y + lineH < msgAreaBottom) {
y += lineH;
display.setCursor(0, y);
int off = remainChars;
while (off < msgLen && y < msgAreaBottom) {
char wrapBuf[256];
int wrapLen = lineW;
if (msgLen - off < wrapLen) wrapLen = msgLen - off;
memcpy(wrapBuf, msg->text + off, wrapLen);
wrapBuf[wrapLen] = '\0';
display.print(wrapBuf);
off += wrapLen;
if (off < msgLen) {
y += lineH;
display.setCursor(0, y);
}
}
}
}
}
y += lineH;
}
// --- Scroll bar (channel screen style) ---
int sbX = display.width() - scrollBarW;
int sbTop = msgAreaTop;
int sbHeight = msgAreaBottom - msgAreaTop;
// Draw track outline
display.setColor(DisplayDriver::LIGHT);
display.drawRect(sbX, sbTop, scrollBarW, sbHeight);
if (_ircMsgCount > _ircLinesPerPage) {
// Scrollable: draw proportional thumb
int maxScroll = _ircMsgCount - _ircLinesPerPage;
if (maxScroll < 1) maxScroll = 1;
int thumbH = (_ircLinesPerPage * sbHeight) / _ircMsgCount;
if (thumbH < 4) thumbH = 4;
// _ircScrollPos=0 is newest (bottom), so invert for thumb position
int thumbY = sbTop + ((maxScroll - _ircScrollPos) * (sbHeight - thumbH)) / maxScroll;
for (int ty = thumbY + 1; ty < thumbY + thumbH - 1; ty++)
display.drawRect(sbX + 1, ty, scrollBarW - 2, 1);
} else {
// All messages fit: fill entire track
for (int ty = sbTop + 1; ty < sbTop + sbHeight - 1; ty++)
display.drawRect(sbX + 1, ty, scrollBarW - 2, 1);
}
}
bool handleIRCChatInput(char c) {
if (_ircComposing) {
if (c == '\r' || c == 13) {
if (_ircComposeLen > 0) {
sendIRCMessage();
}
// Always exit compose on Enter (after send or when empty)
_ircComposing = false;
return true;
}
if (c == '\b' || c == 127) {
if (_ircComposeLen > 0) {
_ircCompose[--_ircComposeLen] = '\0';
} else {
// Backspace on empty compose exits compose mode
_ircComposing = false;
}
return true;
}
if (c == 0x1B) { // ESC exits compose
_ircComposing = false;
return true;
}
if (c >= 32 && c < 127 && _ircComposeLen < IRC_COMPOSE_MAX - 1) {
_ircCompose[_ircComposeLen++] = c;
_ircCompose[_ircComposeLen] = '\0';
return true;
}
return true; // Consume all keys while composing
}
// Non-composing mode
if (c == '\r' || c == 13) {
_ircComposing = true;
_ircComposeLen = 0;
_ircCompose[0] = '\0';
return true;
}
// W - scroll up (older messages)
if (c == 'w' || c == 'W' || c == 0xF2) {
int maxScroll = _ircMsgCount - _ircLinesPerPage;
if (maxScroll < 0) maxScroll = 0;
if (_ircScrollPos < maxScroll) _ircScrollPos++;
return true;
}
// S - scroll down (newer messages)
if (c == 's' || c == 'S' || c == 0xF1) {
if (_ircScrollPos > 0) _ircScrollPos--;
return true;
}
// Q - back to home (keep connection alive)
if (c == 'q' || c == 'Q') {
_mode = HOME;
_homeSelected = 0;
return true;
}
// X - disconnect
if (c == 'x' || c == 'X') {
disconnectIRC();
_mode = HOME;
_homeSelected = 0;
return true;
}
return false;
}
bool isIRCSetupEditing() const {
return _mode == IRC_SETUP && _ircSetupEditing;
}
bool isIRCComposing() const {
return _mode == IRC_CHAT && _ircComposing;
}
public:
WebReaderScreen(UITask* task, NodePrefs* prefs = nullptr)
: _task(task), _prefs(prefs), _mode(HOME), _initialized(false), _lastFontPref(0), _display(nullptr),
_charsPerLine(40), _linesPerPage(15), _lineHeight(5), _footerHeight(14),
_wifiState(WIFI_IDLE), _ssidCount(0), _selectedSSID(0), _wifiPassLen(0),
_urlLen(0), _urlCursor(0),
_textBuffer(nullptr), _textLen(0), _links(nullptr), _linkCount(0),
_currentPage(0), _totalPages(0),
_homeSelected(0), _homeScrollY(0), _urlEditing(false),
_searchEditing(false), _searchLen(0),
_linkInput(0), _linkInputActive(false),
_formCount(0), _forms(nullptr), _activeForm(-1), _activeField(0),
_formFieldEditing(false), _formEditLen(0), _formLastCharAt(0),
_cookies(nullptr), _cookieCount(0),
_fetchStartTime(0), _fetchProgress(0), _fetchRetryCount(0),
_tlsClient(nullptr),
_downloadOk(false),
_requestTextReader(false),
_ircClient(nullptr), _ircUseTLS(false), _ircConnected(false), _ircRegistered(false),
_ircJoined(false), _ircPort(6697), _ircMessages(nullptr),
_ircMsgHead(0), _ircMsgCount(0), _ircLineLen(0),
_ircComposeLen(0), _ircComposing(false), _ircScrollPos(0), _ircLinesPerPage(12),
_ircSetupField(0), _ircSetupBufLen(0), _ircSetupEditing(false),
_ircLastDataTime(0), _ircReconnectAt(0),
_ircDirty(false), _ircLastRender(0) {
_urlBuffer[0] = '\0';
_searchBuffer[0] = '\0';
_wifiPass[0] = '\0';
_pageTitle[0] = '\0';
_currentUrl[0] = '\0';
_formEditBuf[0] = '\0';
_downloadedFile[0] = '\0';
_ircHost[0] = '\0';
_ircNick[0] = '\0';
_ircChannel[0] = '\0';
_ircCompose[0] = '\0';
_ircSetupBuf[0] = '\0';
_ircLineBuf[0] = '\0';
_toastMsg[0] = '\0';
_toastTime = 0;
// Allocate forms and cookies in PSRAM to free internal heap for TLS
_forms = (WebForm*)ps_calloc(WEB_MAX_FORMS, sizeof(WebForm));
_cookies = (Cookie*)ps_calloc(WEB_MAX_COOKIES, sizeof(Cookie));
loadIRCConfig();
}
~WebReaderScreen() {
freeBuffers();
if (_forms) { free(_forms); _forms = nullptr; }
if (_cookies) { free(_cookies); _cookies = nullptr; }
if (_ircClient) {
if (_ircClient->connected()) _ircClient->stop();
delete _ircClient;
_ircClient = nullptr;
}
if (_ircMessages) { free(_ircMessages); _ircMessages = nullptr; }
if (_tlsClient) { delete _tlsClient; _tlsClient = nullptr; }
}
// Called when entering the web reader screen
void enter(DisplayDriver& display) {
_display = &display;
_fetchError = ""; // Clear stale errors from previous session
initLayout(display);
loadBookmarks();
loadHistory();
// Check if already connected to a network
if (isNetworkAvailable()) {
_wifiState = WIFI_CONNECTED;
_mode = (_textLen > 0) ? READING : HOME;
Serial.printf("WebReader enter: already connected, mode=%d\n", _mode);
return;
}
// Not connected — try auto-connect from saved credentials first.
// Show a status screen during the blocking wait (up to 5s).
Serial.println("WebReader enter: not connected, trying auto-connect");
if (_display) {
_display->startFrame();
_display->setColor(DisplayDriver::GREEN);
_display->setTextSize(1);
_display->setCursor(0, 0);
_display->print("Web Reader");
_display->drawRect(0, 11, _display->width(), 1);
_display->setColor(DisplayDriver::LIGHT);
_display->setTextSize(_prefs->smallTextSize());
_display->setCursor(0, 18);
_display->print("Connecting to WiFi...");
_display->endFrame();
}
if (loadAndAutoConnect()) {
Serial.printf("WebReader enter: auto-connect OK\n");
showConnectedAndGoHome();
return;
}
// No saved credentials or auto-connect failed — prompt user for WiFi setup.
// This must happen BEFORE showing the URL entry page so the user isn't
// asked to type a URL they can't fetch.
Serial.println("WebReader enter: auto-connect failed, starting WiFi setup");
_mode = WIFI_SETUP;
startWifiScan(); // Shows its own "Scanning..." splash, then blocks
directRedraw(); // Replace splash with scan results or error screen
Serial.printf("WebReader enter: mode=WIFI_SETUP, wifiState=%d\n", _wifiState);
}
// Called when leaving the screen
void exitReader() {
Serial.printf("WebReader: exitReader - heap before: %d, largest: %d\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
// Disconnect IRC if active
if (_ircClient) {
if (_ircClient->connected()) {
// Send QUIT before disconnecting
_ircClient->println("QUIT :leaving");
delay(100);
_ircClient->stop();
}
delete _ircClient;
_ircClient = nullptr;
}
_ircConnected = false;
_ircRegistered = false;
_ircJoined = false;
_ircReconnectAt = 0;
// Free PSRAM buffers (text, links — will be re-allocated on next fetch)
freeBuffers();
// Free IRC message ring buffer
if (_ircMessages) { free(_ircMessages); _ircMessages = nullptr; }
_ircMsgHead = 0;
_ircMsgCount = 0;
// Clear String vectors to release heap fragments
// (bookmarks/history are reloaded from SD on re-entry via enter())
_bookmarks.clear();
_bookmarks.shrink_to_fit();
_history.clear();
_history.shrink_to_fit();
_backHistory.clear();
_backHistory.shrink_to_fit();
_pageOffsets.clear();
_pageOffsets.shrink_to_fit();
// Clear Arduino Strings that may hold heap allocations
_fetchError = String();
_connectedSSID = String();
// Destroy persistent TLS client before WiFi shutdown
if (_tlsClient) { delete _tlsClient; _tlsClient = nullptr; }
_tlsHost = String();
#ifdef MECK_WIFI_COMPANION
// WiFi companion: keep WiFi alive for the companion TCP server.
// Don't disconnect or change mode — just reset our internal state.
_wifiState = WIFI_CONNECTED; // WiFi is still up
#else
// Shut down WiFi to reclaim ~50-70KB internal RAM
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
_wifiState = WIFI_IDLE;
#endif
// Reset mode so re-entry starts fresh
_mode = HOME;
_homeSelected = 0;
_homeScrollY = 0;
_urlEditing = false;
_searchEditing = false;
Serial.printf("WebReader: exitReader - heap after: %d, largest: %d\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
}
bool isReading() const { return _mode == READING; }
bool isHome() const { return _mode == HOME; }
bool wantsTextReader() const { return _requestTextReader; }
void clearTextReaderRequest() { _requestTextReader = false; }
bool isUrlEditing() const { return _urlEditing && _mode == HOME; }
bool isWifiSetup() const { return _mode == WIFI_SETUP; }
bool isPasswordEntry() const {
return _mode == WIFI_SETUP && _wifiState == WIFI_ENTERING_PASS;
}
bool isFormFilling() const {
return _mode == FORM_FILL && _formFieldEditing;
}
bool isSearchEditing() const {
return _searchEditing && _mode == HOME;
}
bool isIRCMode() const { return _mode == IRC_CHAT || _mode == IRC_SETUP; }
bool isIRCTextEntry() const {
return (_mode == IRC_CHAT && _ircComposing) ||
(_mode == IRC_SETUP && _ircSetupEditing);
}
// ---- Accessors for T5S3 touch mapping and VKB integration ----
int getHomeSelected() const { return _homeSelected; }
int getLinkCount() const { return _linkCount; }
int getBookmarkCount() const { return (int)_bookmarks.size(); }
const char* getUrlText() const { return _urlBuffer; }
// Set URL text and activate editing mode (for VKB submit)
void setUrlText(const char* text) {
strncpy(_urlBuffer, text, WEB_MAX_URL_LEN - 1);
_urlBuffer[WEB_MAX_URL_LEN - 1] = '\0';
_urlLen = strlen(_urlBuffer);
_urlEditing = true;
}
// Set search text and activate editing mode (for VKB submit)
void setSearchText(const char* text) {
strncpy(_searchBuffer, text, sizeof(_searchBuffer) - 1);
_searchBuffer[sizeof(_searchBuffer) - 1] = '\0';
_searchLen = strlen(_searchBuffer);
_searchEditing = true;
}
// Set WiFi password text (for VKB submit)
void setWifiPassText(const char* text) {
strncpy(_wifiPass, text, WEB_WIFI_PASS_LEN - 1);
_wifiPass[WEB_WIFI_PASS_LEN - 1] = '\0';
_wifiPassLen = strlen(_wifiPass);
}
// Returns true if a password reveal is active and needs a refresh after expiry
bool needsRevealRefresh() const {
if (_formLastCharAt > 0 && (millis() - _formLastCharAt) < 900) {
if (_mode == FORM_FILL && _formFieldEditing) return true;
if (_mode == WIFI_SETUP && _wifiState == WIFI_ENTERING_PASS) return true;
}
return false;
}
// Direct render — bypasses UITask's render scheduling.
// Called from main.cpp during URL/password text entry for responsive typing.
void directRedraw() {
if (!_display) return;
_display->startFrame();
render(*_display);
_display->endFrame();
_ircDirty = false;
_ircLastRender = millis();
}
int render(DisplayDriver& display) override {
switch (_mode) {
case WIFI_SETUP:
renderWifiSetup(display);
return 1000;
case HOME:
renderHome(display);
return 5000;
case FETCHING:
renderFetching(display);
return 500;
case READING:
renderReading(display);
return 5000;
case LINK_SELECT:
renderReading(display);
return 1000;
case FORM_FILL:
renderFormFill(display);
return 1000;
case IRC_SETUP:
renderIRCSetup(display);
return 1000;
case IRC_CHAT:
renderIRCChat(display);
return 500; // Fast refresh for live chat
case DOWNLOAD_DONE:
renderDownloadDone(display);
return 5000;
default:
return 5000;
}
}
void poll() override {
// Poll IRC connection (regardless of current mode, to keep connection alive)
if (_ircConnected || _ircReconnectAt > 0) {
pollIRC();
}
// Auto-render when new IRC messages arrive (e-ink throttled to ~1s)
if (_ircDirty && _mode == IRC_CHAT && _display) {
unsigned long now = millis();
if (now - _ircLastRender >= 900) {
_display->startFrame();
render(*_display);
_display->endFrame();
_ircLastRender = now;
_ircDirty = false;
}
}
// Handle async WiFi operations
if (_mode == WIFI_SETUP) {
if (_wifiState == WIFI_SCANNING) {
checkWifiScan();
} else if (_wifiState == WIFI_CONNECTING) {
checkWifiConnect();
if (_wifiState == WIFI_CONNECTED) {
// Show "Connected!" confirmation then go to URL entry
if (_urlLen > 0) {
// URL was pending — fetch it directly
fetchWithSelfRef(_urlBuffer);
} else {
showConnectedAndGoHome();
}
}
}
}
}
bool handleInput(char c) override {
switch (_mode) {
case WIFI_SETUP:
return handleWifiInput(c);
case HOME:
return handleHomeInput(c);
case READING:
case LINK_SELECT:
return handleReadingInput(c);
case FORM_FILL:
return handleFormFillInput(c);
case IRC_SETUP:
return handleIRCSetupInput(c);
case IRC_CHAT:
return handleIRCChatInput(c);
case FETCHING:
// Q to cancel fetch (can't actually cancel HTTP mid-stream, but
// go back to home)
if (c == 'q' || c == 'Q') {
_mode = HOME;
return true;
}
return false;
case DOWNLOAD_DONE:
if ((c == '\r' || c == 13) && _downloadOk) {
// Set flag — main.cpp will navigate to text reader
exitReader();
_requestTextReader = true;
return true;
}
if (c == 'q' || c == 'Q') {
_mode = HOME;
_homeSelected = 0;
return true;
}
return true; // Consume all other keys
default:
return false;
}
}
};