From 27b8ea603f43c3d48f0d268b0d167d99aa3823f4 Mon Sep 17 00:00:00 2001 From: pelgraine <140762863+pelgraine@users.noreply.github.com> Date: Sun, 22 Feb 2026 00:10:02 +1100 Subject: [PATCH] preliminary html web reader app stage 1 --- Web App Guide.md | 57 + examples/companion_radio/MyMesh.h | 4 +- examples/companion_radio/main.cpp | 173 +- examples/companion_radio/ui-new/UITask.cpp | 26 + examples/companion_radio/ui-new/UITask.h | 16 + .../companion_radio/ui-new/Webreaderscreen.h | 3008 +++++++++++++++++ .../companion_radio/ui-new/webreaderdeps.cpp | 16 + platformio.ini | 10 +- variants/lilygo_tdeck_pro/platformio.ini | 5 +- 9 files changed, 3300 insertions(+), 15 deletions(-) create mode 100644 Web App Guide.md create mode 100644 examples/companion_radio/ui-new/Webreaderscreen.h create mode 100644 examples/companion_radio/ui-new/webreaderdeps.cpp diff --git a/Web App Guide.md b/Web App Guide.md new file mode 100644 index 0000000..80512a9 --- /dev/null +++ b/Web App Guide.md @@ -0,0 +1,57 @@ +# Web Reader - Integration Summary + +### Conditional Compilation +All web reader code is wrapped in `#ifdef MECK_WEB_READER` guards. The flag is set: +- **meck_audio_ble**: Yes (`-D MECK_WEB_READER=1`) — WiFi available via BLE radio stack +- **meck_4g_ble**: Yes (`-D MECK_WEB_READER=1`) — WiFi now, PPP via A7682E in future +- **meck_audio_standalone**: No — excluded to preserve zero-radio-power design + +### 4G Modem / PPP Support +The web reader uses `isNetworkAvailable()` which checks both WiFi and (future) PPP connectivity. The `fetchPage()` method uses ESP32's standard `HTTPClient` which routes through whatever network interface is active — WiFi or PPP. + +When PPP support is added to the 4G modem driver, the web reader will work over cellular automatically without code changes. The `isNetworkAvailable()` method has a `TODO` placeholder for the PPP status check. + +--- + +## Key Bindings + +### From Home Screen +| Key | Action | +|-----|--------| +| `b` | Open web reader | + +### Web Reader - Home View +| Key | Action | +|-----|--------| +| `w` / `s` | Navigate up/down in bookmarks/history | +| `Enter` | Select URL bar or bookmark/history item | +| Type | Enter URL (when URL bar is active) | +| `q` | Exit to firmware home | + +### Web Reader - Reading View +| Key | Action | +|-----|--------| +| `w` / `a` | Previous page | +| `s` / `d` / `Space` | Next page | +| `l` or `Enter` | Enter link selection (type link number) | +| `g` | Go to new URL (return to web reader home) | +| `k` | Bookmark current page | +| `q` | Back to web reader home | + +### Web Reader - WiFi Setup +| Key | Action | +|-----|--------| +| `w` / `s` | Navigate SSID list | +| `Enter` | Select SSID / submit password / retry | +| Type | Enter WiFi password | +| `q` | Back | + +--- + +## SD Card Structure +``` +/web/ + wifi.cfg - Saved WiFi credentials (auto-reconnect) + bookmarks.txt - One URL per line + history.txt - Recent URLs, newest first +``` \ No newline at end of file diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 245c28f..c1b9708 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -8,11 +8,11 @@ #define FIRMWARE_VER_CODE 8 #ifndef FIRMWARE_BUILD_DATE -#define FIRMWARE_BUILD_DATE "20 Feb 2026" +#define FIRMWARE_BUILD_DATE "22 Feb 2026" #endif #ifndef FIRMWARE_VERSION -#define FIRMWARE_VERSION "Meck v0.9.2" +#define FIRMWARE_VERSION "Meck v0.9.3" #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 95a08d6..3a8dc0d 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -1,4 +1,5 @@ #include // needed for PlatformIO +#include // for esp_bt_controller_mem_release (web reader WiFi) #include #include "MyMesh.h" #include "variant.h" // Board-specific defines (HAS_GPS, etc.) @@ -16,6 +17,9 @@ #include "ChannelScreen.h" #include "SettingsScreen.h" #include "RepeaterAdminScreen.h" + #ifdef MECK_WEB_READER + #include "WebReaderScreen.h" + #endif extern SPIClass displaySpi; // From GxEPDDisplay.cpp, shared SPI bus TCA8418Keyboard keyboard(I2C_ADDR_KEYBOARD, &Wire); @@ -33,6 +37,11 @@ static bool composeDM = false; static int composeDMContactIdx = -1; static char composeDMName[32]; + #ifdef MECK_WEB_READER + static unsigned long lastWebReaderRefresh = 0; + static bool webReaderNeedsRefresh = false; + static bool webReaderTextEntry = false; // True when URL/password entry active + #endif // AGC reset - periodically re-assert RX boosted gain to prevent sensitivity drift #define AGC_RESET_INTERVAL_MS 500 static unsigned long lastAGCReset = 0; @@ -691,10 +700,21 @@ void loop() { #else bool smsSuppressLoop = false; #endif - if (!composeMode && !notesSuppressLoop && !smsSuppressLoop) { + #ifdef MECK_WEB_READER + // Safety: clear web reader text entry flag if we're no longer on the web reader + if (webReaderTextEntry && !ui_task.isOnWebReader()) { + webReaderTextEntry = false; + webReaderNeedsRefresh = false; + } + #endif + if (!composeMode && !notesSuppressLoop && !smsSuppressLoop + #ifdef MECK_WEB_READER + && !webReaderTextEntry + #endif + ) { ui_task.loop(); } else { - // Handle debounced screen refresh (compose, emoji picker, or notes editor) + // Handle debounced screen refresh (compose, emoji picker, notes, or web reader text entry) if (composeNeedsRefresh && (millis() - lastComposeRefresh) >= COMPOSE_REFRESH_INTERVAL) { if (composeMode) { if (emojiPickerMode) { @@ -708,7 +728,7 @@ void loop() { ui_task.loop(); } else if (smsSuppressLoop) { // SMS compose: render directly to display, same as mesh compose - #ifdef DISPLAY_CLASS + #if defined(DISPLAY_CLASS) && defined(HAS_4G_MODEM) display.startFrame(); ((SMSScreen*)ui_task.getSMSScreen())->render(display); display.endFrame(); @@ -717,6 +737,28 @@ void loop() { lastComposeRefresh = millis(); composeNeedsRefresh = false; } + #ifdef MECK_WEB_READER + if (webReaderNeedsRefresh && (millis() - lastWebReaderRefresh) >= COMPOSE_REFRESH_INTERVAL) { + WebReaderScreen* wr2 = (WebReaderScreen*)ui_task.getWebReaderScreen(); + if (wr2) { + display.startFrame(); + wr2->render(display); + display.endFrame(); + } + lastWebReaderRefresh = millis(); + webReaderNeedsRefresh = false; + } + // Password reveal expiry: re-render to mask character after 800ms + if (webReaderTextEntry && !webReaderNeedsRefresh) { + WebReaderScreen* wr3 = (WebReaderScreen*)ui_task.getWebReaderScreen(); + if (wr3 && wr3->needsRevealRefresh() && (millis() - lastWebReaderRefresh) >= 850) { + display.startFrame(); + wr3->render(display); + display.endFrame(); + lastWebReaderRefresh = millis(); + } + } + #endif } // Track reader/notes/audiobook mode state for key routing readerMode = ui_task.isOnTextReader(); @@ -1214,6 +1256,51 @@ void handleKeyboardInput() { } #endif + // *** WEB READER TEXT INPUT MODE *** + // Match compose mode pattern: key handler sets a flag and returns instantly. + // Main loop renders with 100ms debounce (same as COMPOSE_REFRESH_INTERVAL). + // This way the key handler never blocks for 648ms during a render. +#ifdef MECK_WEB_READER + if (ui_task.isOnWebReader()) { + WebReaderScreen* wr = (WebReaderScreen*)ui_task.getWebReaderScreen(); + bool urlEdit = wr ? wr->isUrlEditing() : false; + bool passEdit = wr ? wr->isPasswordEntry() : false; + bool formEdit = wr ? wr->isFormFilling() : false; + if (wr && (urlEdit || passEdit || formEdit)) { + webReaderTextEntry = true; // Suppress ui_task.loop() in main loop + wr->handleInput(key); // Updates buffer instantly, no render + + // Check if text entry ended (submitted, cancelled, etc.) + if (!wr->isUrlEditing() && !wr->isPasswordEntry() && !wr->isFormFilling()) { + // Text entry ended + webReaderTextEntry = false; + webReaderNeedsRefresh = false; + // fetchPage()/submitForm() handle their own rendering, or mode changed — + // let ui_task.loop() resume on next iteration + } else { + // Still typing — request debounced refresh + webReaderNeedsRefresh = true; + lastWebReaderRefresh = millis(); + } + return; + } else { + // Not in text entry — clear flag so ui_task.loop() resumes + webReaderTextEntry = false; + + // Q from HOME mode exits the web reader entirely (like text reader) + if ((key == 'q' || key == 'Q') && wr && wr->isHome() && !wr->isUrlEditing()) { + Serial.println("Exiting web reader"); + ui_task.gotoHomeScreen(); + return; + } + + // Route keys through normal UITask for navigation/scrolling + ui_task.injectKey(key); + return; + } + } +#endif + // Normal mode - not composing switch (key) { case 'c': @@ -1258,6 +1345,50 @@ void handleKeyboardInput() { ui_task.gotoSMSScreen(); break; #endif + + #ifdef MECK_WEB_READER + case 'b': + // Open web reader (browser) + Serial.println("Opening web reader"); + { + static bool webReaderWifiReady = false; + if (!webReaderWifiReady) { + // WiFi needs ~40KB contiguous heap. The BLE controller holds ~30KB, + // leaving only ~30KB largest block. We MUST release BLE memory first. + // + // This disables BLE for the duration of the session. + // BLE comes back on reboot. + Serial.printf("WebReader: heap BEFORE BT release: free=%d, largest=%d\n", + ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + + // 1) Stop BLE controller (disable + deinit) + btStop(); + delay(50); + + // 2) Release the BT controller's reserved memory region back to heap + esp_bt_controller_mem_release(ESP_BT_MODE_BTDM); + delay(50); + + Serial.printf("WebReader: heap AFTER BT release: free=%d, largest=%d\n", + ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + + // 3) Now init WiFi while we have maximum contiguous heap + if (WiFi.mode(WIFI_STA)) { + Serial.println("WebReader: WiFi STA init OK"); + webReaderWifiReady = true; + } else { + Serial.println("WebReader: WiFi STA init FAILED even after BT release"); + // Clean up partial WiFi init to avoid memory leak + WiFi.mode(WIFI_OFF); + } + + Serial.printf("WebReader: heap after WiFi init: free=%d, largest=%d\n", + ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + } + } + ui_task.gotoWebReader(); + break; + #endif case 'n': // Open notes @@ -1274,9 +1405,13 @@ void handleKeyboardInput() { break; case 's': - // Open settings (from home), or navigate down on channel/contacts/admin - if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()) { - ui_task.injectKey('s'); // Pass directly for channel/contacts scrolling + // Open settings (from home), or navigate down on channel/contacts/admin/web + if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin() +#ifdef MECK_WEB_READER + || ui_task.isOnWebReader() +#endif + ) { + ui_task.injectKey('s'); // Pass directly for scrolling } else { Serial.println("Opening settings"); ui_task.gotoSettingsScreen(); @@ -1285,8 +1420,12 @@ void handleKeyboardInput() { case 'w': // Navigate up/previous (scroll on channel screen) - if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()) { - ui_task.injectKey('w'); // Pass directly for channel/contacts switching + if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin() +#ifdef MECK_WEB_READER + || ui_task.isOnWebReader() +#endif + ) { + ui_task.injectKey('w'); // Pass directly for scrolling } else { Serial.println("Nav: Previous"); ui_task.injectKey(0xF2); // KEY_PREV @@ -1371,6 +1510,17 @@ void handleKeyboardInput() { break; } } +#ifdef MECK_WEB_READER + // If web reader is in reading/link/wifi mode, inject q for internal navigation + // (reading→home, wifi→home). Only exit to firmware home if already on web home. + if (ui_task.isOnWebReader()) { + WebReaderScreen* wr = (WebReaderScreen*)ui_task.getWebReaderScreen(); + if (wr && !wr->isHome()) { + ui_task.injectKey('q'); + break; + } + } +#endif // Go back to home screen (admin mode handled above) Serial.println("Nav: Back to home"); ui_task.gotoHomeScreen(); @@ -1395,6 +1545,13 @@ void handleKeyboardInput() { break; default: +#ifdef MECK_WEB_READER + // Pass unhandled keys to web reader (l=link, g=go, k=bookmark, 0-9=link#) + if (ui_task.isOnWebReader()) { + ui_task.injectKey(key); + break; + } +#endif Serial.printf("Unhandled key in normal mode: '%c' (0x%02X)\n", key, key); break; } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index c4c9323..c6e423b 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -340,6 +340,10 @@ public: display.drawTextCentered(display.width() / 2, y, "[E] Reader [P] Audiobooks"); #else display.drawTextCentered(display.width() / 2, y, "[E] Reader "); +#endif +#ifdef MECK_WEB_READER + y += 10; + display.drawTextCentered(display.width() / 2, y, "[B] Browser "); #endif y += 14; @@ -1446,6 +1450,28 @@ void UITask::gotoRepeaterAdmin(int contactIdx) { _next_refresh = 100; } +#ifdef MECK_WEB_READER +void UITask::gotoWebReader() { + // Lazy-initialize on first use (same pattern as audiobook player) + if (web_reader == nullptr) { + Serial.printf("WebReader: lazy init - free heap: %d, largest block: %d\n", + ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + web_reader = new WebReaderScreen(this); + Serial.printf("WebReader: init complete - free heap: %d\n", ESP.getFreeHeap()); + } + WebReaderScreen* wr = (WebReaderScreen*)web_reader; + if (_display != NULL) { + wr->enter(*_display); + } + setCurrScreen(web_reader); + if (_display != NULL && !_display->isOn()) { + _display->turnOn(); + } + _auto_off = millis() + AUTO_OFF_MILLIS; + _next_refresh = 100; +} +#endif + void UITask::onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) { if (repeater_admin && isOnRepeaterAdmin()) { ((RepeaterAdminScreen*)repeater_admin)->onLoginResult(success, permissions, server_time); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index ba75db1..5ac35fc 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -26,6 +26,10 @@ #include "SMSScreen.h" #endif +#ifdef MECK_WEB_READER + #include "WebReaderScreen.h" +#endif + class UITask : public AbstractUITask { DisplayDriver* _display; SensorManager* _sensors; @@ -66,6 +70,9 @@ class UITask : public AbstractUITask { UIScreen* sms_screen; // SMS messaging screen (4G variant only) #endif UIScreen* repeater_admin; // Repeater admin screen +#ifdef MECK_WEB_READER + UIScreen* web_reader; // Web reader screen (lazy-init, WiFi required) +#endif UIScreen* curr; void userLedHandler(); @@ -97,6 +104,9 @@ public: void gotoOnboarding(); // Navigate to settings in onboarding mode void gotoAudiobookPlayer(); // Navigate to audiobook player void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin +#ifdef MECK_WEB_READER + void gotoWebReader(); // Navigate to web reader (browser) +#endif #ifdef HAS_4G_MODEM void gotoSMSScreen(); bool isOnSMSScreen() const { return curr == sms_screen; } @@ -114,6 +124,9 @@ public: bool isOnSettingsScreen() const { return curr == settings_screen; } bool isOnAudiobookPlayer() const { return curr == audiobook_screen; } bool isOnRepeaterAdmin() const { return curr == repeater_admin; } +#ifdef MECK_WEB_READER + bool isOnWebReader() const { return curr == web_reader; } +#endif #ifdef MECK_AUDIO_VARIANT // Check if audio is playing/paused in the background (for status indicators) @@ -150,6 +163,9 @@ public: UIScreen* getAudiobookScreen() const { return audiobook_screen; } void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; } UIScreen* getRepeaterAdminScreen() const { return repeater_admin; } +#ifdef MECK_WEB_READER + UIScreen* getWebReaderScreen() const { return web_reader; } +#endif // from AbstractUITask void msgRead(int msgcount) override; diff --git a/examples/companion_radio/ui-new/Webreaderscreen.h b/examples/companion_radio/ui-new/Webreaderscreen.h new file mode 100644 index 0000000..de1db74 --- /dev/null +++ b/examples/companion_radio/ui-new/Webreaderscreen.h @@ -0,0 +1,3008 @@ +#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 +#include +#include "variant.h" +#include +#include +#include +#include +#include +#include +#include +#include "Utf8CP437.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" +#define WEB_MAX_PAGE_SIZE 32768 // Max HTML download size (32KB) +#define WEB_MAX_TEXT_SIZE 24576 // Max extracted text size (24KB) +#define WEB_MAX_LINKS 64 // 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