From c529fc15a895e0de8d87c429bc8bba0297703146 Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Tue, 1 Sep 2026 10:59:25 +1000 Subject: [PATCH] Release v3.1.0 --- .github/workflows/ci.yml | 2 + CHANGELOG.md | 2 + README.md | 31 ++++- docs/DEVELOPMENT.md | 7 + docs/GETTING_STARTED.md | 2 +- docs/PORTAL_CUSTOMIZATION.md | 65 --------- docs/PORTAL_UI.md | 80 +++++++++++ docs/README.md | 4 +- docs/TESTING.md | 4 +- examples/BrandedPortal/BrandedPortal.ino | 50 +++++++ lib/WiFiManager/include/WiFiManager.h | 38 +++--- lib/WiFiManager/include/WiFiManagerHandlers.h | 1 - lib/WiFiManager/include/WiFiManagerPortalUI.h | 93 +++++++++++++ lib/WiFiManager/include/templates/CSS.h | 9 +- .../include/templates/PortalAppJS.h | 55 +++++--- lib/WiFiManager/include/templates/RootShell.h | 6 +- lib/WiFiManager/src/WiFiManager.cpp | 116 ++++++++++++---- lib/WiFiManager/src/WiFiManagerHandlers.cpp | 128 +++++++++--------- library.json | 6 +- platformio.ini | 10 +- scripts/check-docs.sh | 2 +- scripts/test.sh | 7 + test/compile-project/platformio.ini | 4 +- test/compile-project/src/main.cpp | 36 +++++ test/test_wifimanager/test_main.cpp | 4 +- test/test_wifimanager/test_main.h | 4 +- .../tests/test_advanced_options.cpp | 6 +- .../tests/test_configuration.cpp | 6 +- .../tests/test_root_render_lifecycle.cpp | 73 +++++++++- .../tests/test_template_rendering.cpp | 25 ++-- 30 files changed, 628 insertions(+), 248 deletions(-) delete mode 100644 docs/PORTAL_CUSTOMIZATION.md create mode 100644 docs/PORTAL_UI.md create mode 100644 examples/BrandedPortal/BrandedPortal.ino create mode 100644 lib/WiFiManager/include/WiFiManagerPortalUI.h diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac55477..233efbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,8 @@ on: push: branches: - device-framework + tags: + - "v*" pull_request: concurrency: diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ef6d10..8cdb395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## 3.1.0 - Add an opt-in primary/fallback station-profile controller with bounded failover, reconnection, a durable consumer-supplied store, and profile-aware portal APIs. DeviceFramework uses this to persist verified WiFi profiles transactionally. +- Add portable portal branding and presentation hooks, including theme-aware shell and template rendering, without requiring DeviceFramework. +- Pin ESP32 tests to the Arduino 3-compatible pioarduino platform release and resolve DFTE 1.1.0. ## 3.0.6 diff --git a/README.md b/README.md index de706bb..2f2438e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ WiFiManager is the maintained ESP8266/ESP32 configuration-portal fork used by De - **Single-shell portal:** one responsive SPA for WiFi, parameters, information, actions, and firmware update flow. - **Data-first APIs:** portal state and actions are exposed under `/api/...`, not scraped from HTML. -- **Controlled customisation:** supported `portal*` APIs customise branding, parameters, cards, layout, and small style/JS enhancements without exposing portal internals. +- **Controlled portal UI:** semantic identity/theme values and structured portal APIs without exposing portal internals. - **ESP8266 and ESP32:** clean PlatformIO consumers resolve the right asynchronous TCP transport automatically. ## Try it @@ -30,11 +30,38 @@ void loop() {} For a DeviceFramework device, configure the framework’s shared device password instead. DeviceFramework applies it consistently to the provisioning AP, OTA, HTTP Basic authentication, and WebSerial. +### Brand it + +```cpp +const char kTitle[] PROGMEM = "Set up Temperature Monitor"; +const char kBrand[] PROGMEM = "Tree"; +const char kAccent[] PROGMEM = "#347a45"; + +const WiFiManagerPortalConfig kPortalUI = { + WiFiManagerPortalText::progmem(kTitle), + WiFiManagerPortalText::progmem(kBrand), + {}, {}, {}, + { + {}, {}, {}, {}, {}, + WiFiManagerPortalText::progmem(kAccent), + {}, {}, {}, {}, {}, + 10, 0, + }, +}; + +void setup() { + wifi.setPortalConfig(kPortalUI); // before the portal starts + wifi.autoConnect("Device Setup"); +} +``` + +The full standalone example, including a static SVG, is [BrandedPortal](examples/BrandedPortal/BrandedPortal.ino). + ## Explore the portal | Goal | Guide | | --- | --- | -| Brand the portal, add parameters, cards, or small UI enhancements | [Portal customisation](docs/PORTAL_CUSTOMIZATION.md) | +| Brand the portal or add structured built-in content | [Portal UI](docs/PORTAL_UI.md) | | Configure primary/fallback station profiles | [Station profiles](docs/STATION_PROFILES.md) | | Understand the JSON APIs and station-connect handoff | [Portal API](docs/PORTAL_API.md) | | Build a clean consumer or work on this fork | [Testing](docs/TESTING.md) · [Development](docs/DEVELOPMENT.md) | diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 5f9fa7c..2288ff8 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -7,6 +7,13 @@ lib_deps = WiFiManager=symlink:///path/to/WiFiManager ``` +The ESP32 environments pin the PlatformIO-compatible pioarduino 51.03.05 +platform package, which packages official Arduino-ESP32 3.0.5. This avoids the +known six-second asynchronous scan failure in the older 2.0.17 framework. Core +3 also requires the `SOC_WIFI_SUPPORTED`, `Network/src`, and ESP8266-transport +ignore settings shown in this repository `platformio.ini`; keep those settings +when adding an ESP32 environment. + Before a release, update `library.json`, `CHANGELOG.md`, and the relevant public documentation, then run: ```bash diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 26c8b3a..6b92ec1 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -36,6 +36,6 @@ The package manifest resolves DFTE, ESPAsyncWebServer, and the correct TCP trans DeviceFramework creates and configures WiFiManager for its normal lifecycle. Use DeviceFramework’s persistent shared device-password API instead of separately configuring an AP, OTA, HTTP, and WebSerial password. -Next: [portal customisation](PORTAL_CUSTOMIZATION.md) or [portal API](PORTAL_API.md). +Next: [portal UI](PORTAL_UI.md) or [portal API](PORTAL_API.md). Back to [documentation](README.md) · [project overview](../README.md). diff --git a/docs/PORTAL_CUSTOMIZATION.md b/docs/PORTAL_CUSTOMIZATION.md deleted file mode 100644 index 7744c29..0000000 --- a/docs/PORTAL_CUSTOMIZATION.md +++ /dev/null @@ -1,65 +0,0 @@ -# Portal customisation - -The stable extension surface is the `portal*` API and JSON API contract. Templates, undocumented DOM IDs, and SPA routing internals are not consumer extension points. - -## Supported changes - -- `portalSetBrand*` and `portalSetContextIdentityText()` for the visible identity. -- `portalSetPage*`, `portalSetAction*`, `portalSetLayout*`, and `portalSetBehavior*` for built-in capability and layout choices. -- `portalAddParameter()`, including raw HTML blocks inside parameter-rendering surfaces. -- `portalAddInfoSection()` and `portalAddHomeCard()` for structured content. -- `portalAppendCss()`, `portalOverrideCss()`, and `portalAppendJs()` for small presentation enhancements. - -```cpp -WiFiManager wm; - -wm.portalSetBrandTitle("Solar Battery Monitor Setup"); -wm.portalSetContextIdentityText("Solar Battery Monitor"); -wm.portalSetBrandHomeIntro( - "Connect the monitor to WiFi, then review its battery settings." -); -wm.portalSetPageUpdateVisible(false); -wm.portalSetActionEraseVisible(false); -wm.portalSetLayoutParamsLocation(PortalParamsLocation::SetupPage); - -WiFiManagerParameter mqttHost( - "mqtt_host", "MQTT host", "broker.local", 64, - "placeholder='broker.local'" -); -wm.portalAddParameter(&mqttHost); - -PortalHomeCard summary; -summary.id = "battery"; -summary.title = "Battery"; -summary.kind = PortalHomeCardKind::KeyValue; -summary.items.push_back({"voltage", "Voltage", "13.2V"}); -wm.portalAddHomeCard(summary); - -wm.portalAppendJs( - "document.addEventListener('wm:ready', function(event) {" - " console.log('Portal ready', event.detail.boot);" - "});" -); -``` - -## Enhancement events - -Appended JavaScript may enhance the existing SPA through: - -- `wm:ready`, with `event.detail.boot`; -- `wm:view-changed`, with `event.detail.route`. - -It should not replace routing or built-in action flow. - -## Intentional boundary - -The following are fork-level changes, not supported consumer customisation: - -- arbitrary home, information, navigation, or shell HTML injection; -- replacing built-in SPA routes or action flows; -- depending on undocumented DOM IDs or private templates; -- adding a new backend-to-frontend workflow without an API contract. - -For data and endpoint behaviour, see [Portal API](PORTAL_API.md). - -Back to [documentation](README.md) · [project overview](../README.md). diff --git a/docs/PORTAL_UI.md b/docs/PORTAL_UI.md new file mode 100644 index 0000000..eed4b2f --- /dev/null +++ b/docs/PORTAL_UI.md @@ -0,0 +1,80 @@ +# Portal UI + +`WiFiManagerPortalConfig` is the supported presentation API for the built-in provisioning portal. It changes identity and semantic visual tokens only. WiFiManager continues to own portal routes, forms, navigation, captive behavior, reset, and OTA views. + +Apply the configuration before `autoConnect()`, `startConfigPortal()`, or `startWebPortal()`. The configuration is non-owning: text and SVG data must have static firmware lifetime and declare whether it is in RAM or PROGMEM. WiFiManager locks presentation after a portal starts, so active asynchronous responses cannot observe a partial configuration. + +## Standalone branded portal + +```cpp +#include +#include + +WiFiManager wifi; + +namespace { +const char kTitle[] PROGMEM = "Set up Temperature Monitor"; +const char kIdentity[] PROGMEM = "Tree"; +const char kIntro[] PROGMEM = "Connect this device to Wi-Fi."; +const char kLogoAlt[] PROGMEM = "Tree"; +const char kLogo[] PROGMEM = R"svg()svg"; +const char kPage[] PROGMEM = "#f4f7f3"; +const char kSurface[] PROGMEM = "#ffffff"; +const char kAccent[] PROGMEM = "#347a45"; +const char kAccentText[] PROGMEM = "#ffffff"; + +const WiFiManagerPortalConfig kPortalUI = { + WiFiManagerPortalText::progmem(kTitle), + WiFiManagerPortalText::progmem(kIdentity), + WiFiManagerPortalText::progmem(kIntro), + WiFiManagerPortalAsset::svgFromProgmem(kLogo), + WiFiManagerPortalText::progmem(kLogoAlt), + { + WiFiManagerPortalText::progmem(kPage), + WiFiManagerPortalText::progmem(kSurface), + {}, {}, {}, + WiFiManagerPortalText::progmem(kAccent), + {}, + WiFiManagerPortalText::progmem(kAccentText), + {}, {}, {}, + 10, 6, + }, +}; +} + +void setup() { + wifi.setPortalConfig(kPortalUI); + wifi.autoConnect("Temperature Monitor"); +} + +void loop() {} +``` + +The complete buildable example is [BrandedPortal](../examples/BrandedPortal/BrandedPortal.ino). The compile fixture exercises this API on ESP8266 and ESP32. + +## Configuration reference + +Leave a text or colour value empty, or a radius at `0`, to retain the built-in stylesheet value. The default title is `WiFiManager`. + +| Field | Used by | +| --- | --- | +| `title` | Document title and portal heading | +| `identityText` | Product/device line below the heading | +| `homeIntro` | Introductory text on the home view | +| `logo.svg`, `logoAltText` | Optional trusted inline SVG and its accessible label | +| `pageBackground`, `surface`, `text`, `mutedText`, `border` | Portal surfaces and text | +| `accent`, `accentHover`, `accentText` | Primary links and actions | +| `success`, `danger`, `dangerHover` | Status and destructive actions | +| `cornerRadiusPx`, `smallCornerRadiusPx` | Card and compact-control corners, limited to 64 px | + +Theme values accept only simple semantic CSS value syntax and are emitted once into a small `#wm-portal-theme` block. This is deliberately not a raw CSS or JavaScript injection API. An SVG is a trusted compiled firmware asset, never form, MQTT, or network input. + +## Built-in portal capabilities + +Presentation uses one configuration route: `setPortalConfig()`. Existing structured portal capabilities remain separate: `portalAddParameter()`, `portalAddInfoSection()`, `portalAddHomeCard()`, page visibility, and portal behavior configure documented built-in functionality rather than private markup. See [Portal API](PORTAL_API.md) and [Station profiles](STATION_PROFILES.md). + +There is no arbitrary HTML shell, route replacement, navigation injection, raw stylesheet, or script hook. If a product needs a new portal capability, add a narrow WiFiManager contract and test it on both supported targets. + +For a DeviceFramework device, use `DeviceFrameworkUIConfig`; DeviceFramework maps its product-level configuration into this portal API. See [DeviceFramework web UI](https://github.com/alexhopeoconnor/DeviceFramework/blob/main/docs/WEB_UI.md). + +Back to the [documentation index](README.md) or [project overview](../README.md). diff --git a/docs/README.md b/docs/README.md index fdb643a..2646221 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,7 +5,7 @@ This maintained fork intentionally has a narrower, explicit customisation bounda | I want to… | Read | | --- | --- | | Start a basic portal or install a released dependency | [Getting started](GETTING_STARTED.md) | -| Brand the SPA, add parameters/cards, or use the supported hooks | [Portal customisation](PORTAL_CUSTOMIZATION.md) | +| Brand the portal or add structured built-in content | [Portal UI](PORTAL_UI.md) | | Configure primary/fallback station profiles or their portal workflow | [Station profiles](STATION_PROFILES.md) | | Consume JSON endpoints or station-connect status | [Portal API](PORTAL_API.md) | | Build a clean consumer | [Testing](TESTING.md) | @@ -13,7 +13,7 @@ This maintained fork intentionally has a narrower, explicit customisation bounda ## Documentation rules -- Portal customisation owns the supported `portal*` surface. +- Portal UI owns branding, semantic theming, and the supported `portal*` surface. - Portal API owns HTTP response contracts. - The root README is a short onboarding page, not an API reference. diff --git a/docs/TESTING.md b/docs/TESTING.md index e6b4439..de56b48 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1,6 +1,8 @@ # Testing -The clean-consumer check builds a project that declares only WiFiManager. It proves the package manifest resolves DFTE, ESPAsyncWebServer, and the correct ESP8266 or ESP32 TCP dependency without a sibling checkout or attached board. +The clean-consumer check builds a project that declares only WiFiManager. It proves the package manifest resolves DFTE, ESPAsyncWebServer, and the correct ESP8266 or ESP32 TCP dependency without a sibling checkout or attached board. The runner removes a previous local package link before each +check, so dependency resolution uses the current manifest rather than a stale +`.pio` copy. ```bash ./scripts/test.sh compile --platform esp8266 diff --git a/examples/BrandedPortal/BrandedPortal.ino b/examples/BrandedPortal/BrandedPortal.ino new file mode 100644 index 0000000..4a98801 --- /dev/null +++ b/examples/BrandedPortal/BrandedPortal.ino @@ -0,0 +1,50 @@ +#include +#include + +WiFiManager wifi; + +namespace { +const char kPortalTitle[] PROGMEM = "Set up Temperature Monitor"; +const char kPortalIdentity[] PROGMEM = "Tree"; +const char kPortalIntro[] PROGMEM = "Connect this device to Wi-Fi."; +const char kPortalLogoAlt[] PROGMEM = "Tree"; +const char kTreeLogo[] PROGMEM = ""; +const char kPage[] PROGMEM = "#f4f7f3"; +const char kSurface[] PROGMEM = "#ffffff"; +const char kText[] PROGMEM = "#1c251e"; +const char kAccent[] PROGMEM = "#347a45"; +const char kAccentText[] PROGMEM = "#ffffff"; + +const WiFiManagerPortalConfig kPortalUI = { + WiFiManagerPortalText::progmem(kPortalTitle), + WiFiManagerPortalText::progmem(kPortalIdentity), + WiFiManagerPortalText::progmem(kPortalIntro), + WiFiManagerPortalAsset::svgFromProgmem(kTreeLogo), + WiFiManagerPortalText::progmem(kPortalLogoAlt), + { + WiFiManagerPortalText::progmem(kPage), + WiFiManagerPortalText::progmem(kSurface), + WiFiManagerPortalText::progmem(kText), + {}, {}, + WiFiManagerPortalText::progmem(kAccent), + {}, + WiFiManagerPortalText::progmem(kAccentText), + {}, {}, {}, + 10, 6, + }, +}; +} + +void setup() { + Serial.begin(115200); + + if (!wifi.setPortalConfig(kPortalUI)) { + Serial.println("Portal UI configuration was rejected"); + } + wifi.setConfigPortalTimeout(180); + if (!wifi.autoConnect("Temperature Monitor")) { + ESP.restart(); + } +} + +void loop() {} diff --git a/lib/WiFiManager/include/WiFiManager.h b/lib/WiFiManager/include/WiFiManager.h index 80e4791..576c2c1 100644 --- a/lib/WiFiManager/include/WiFiManager.h +++ b/lib/WiFiManager/include/WiFiManager.h @@ -142,6 +142,7 @@ #include // Include utility functions #include "WiFiManagerUtils.h" +#include "WiFiManagerPortalUI.h" // A station profile is intentionally fixed-size. ESP Wi-Fi accepts one station // configuration at a time, so multi-network behaviour belongs to the @@ -265,10 +266,11 @@ struct PortalHomeCard { }; struct PortalBrandState { - String title = "WiFiManager"; - String identityTextOverride; - String homeIntro; - String logoSvg; + WiFiManagerPortalText title; + WiFiManagerPortalText identityTextOverride; + WiFiManagerPortalText homeIntro; + WiFiManagerPortalAsset logo; + WiFiManagerPortalText logoAltText; }; struct PortalPageState { @@ -290,12 +292,6 @@ struct PortalLayoutState { bool paramsOnWifiPage = true; }; -struct PortalAssetState { - String appendedCss; - String overriddenCss; - String appendedJs; -}; - struct PortalStructuredExtrasState { std::vector infoSections; std::vector homeCards; @@ -594,11 +590,10 @@ class WiFiManager // clean connect, always disconnect before connecting void setCleanConnect(bool enable); // default false - // ---- Portal (all customization entry points use the portal* prefix) ---- - void portalSetBrandTitle(const String& title); - void portalSetContextIdentityText(const String& identityText); - void portalSetBrandHomeIntro(const String& text); - void portalSetBrandLogoSvg(const String& svgMarkup); + // ---- Portal presentation ---- + // Apply this before a portal is started. The portal response model is + // immutable while active so async responses never observe partial UI state. + bool setPortalConfig(const WiFiManagerPortalConfig& config); void portalSetPageInfoVisible(bool visible); void portalSetPageUpdateVisible(bool visible); @@ -631,10 +626,6 @@ class WiFiManager void portalAddHomeCard(const PortalHomeCard& card); void portalClearHomeCards(); - void portalAppendCss(const String& css); - void portalOverrideCss(const String& css); - void portalAppendJs(const String& js); - // get last connection result, including autoconnect and portal credential-save attempts uint8_t getLastConxResult(); @@ -890,14 +881,19 @@ class WiFiManager boolean _disableConfigPortal = true; // FOR autoconnect - stop config portal if cp wifi save String _hostname = ""; // hostname for esp8266 for dhcp, and or MDNS - // Grouped portal presentation / customization (see portal* setters; JSON in v2 bootstrap) + // Grouped portal presentation / customization (see portal APIs and JSON bootstrap) PortalBrandState _portalBrand; + WiFiManagerPortalTheme _portalTheme; + String _portalThemeStyle; PortalPageState _portalPages; PortalActionState _portalActions; PortalLayoutState _portalLayout; - PortalAssetState _portalAssets; PortalStructuredExtrasState _portalStructured; + bool canChangePortalPresentation() const; + bool isPortalThemeValid(const WiFiManagerPortalTheme& theme) const; + void rebuildPortalThemeStyle(); + // internal options // wifiscan notes diff --git a/lib/WiFiManager/include/WiFiManagerHandlers.h b/lib/WiFiManager/include/WiFiManagerHandlers.h index 3d78ca0..594385e 100644 --- a/lib/WiFiManager/include/WiFiManagerHandlers.h +++ b/lib/WiFiManager/include/WiFiManagerHandlers.h @@ -106,7 +106,6 @@ class WiFiManagerHandlers { void appendPortalJsonStaticFields(String& json, bool& first); void appendPortalJsonCustomParams(String& json, bool& first); - String composePortalStylesheet() const; void appendPortalExtraInfoSectionsJson(String& json, bool& first); void appendPortalExtraHomeCardsJson(String& json, bool& first); diff --git a/lib/WiFiManager/include/WiFiManagerPortalUI.h b/lib/WiFiManager/include/WiFiManagerPortalUI.h new file mode 100644 index 0000000..5b6e187 --- /dev/null +++ b/lib/WiFiManager/include/WiFiManagerPortalUI.h @@ -0,0 +1,93 @@ +/** + * WiFiManagerPortalUI.h + * + * Deliberate, portal-specific presentation configuration. This header does + * not depend on DeviceFramework: WiFiManager remains useful on its own. + */ + +#ifndef WIFI_MANAGER_PORTAL_UI_H +#define WIFI_MANAGER_PORTAL_UI_H + +#include + +enum class WiFiManagerPortalStorage : uint8_t { + Ram, + Progmem, +}; + +/** Non-owning static text. The caller retains the data for the firmware lifetime. */ +struct WiFiManagerPortalText { + const char* data = nullptr; + WiFiManagerPortalStorage storage = WiFiManagerPortalStorage::Ram; + + static constexpr WiFiManagerPortalText ram(const char* value) { + return {value, WiFiManagerPortalStorage::Ram}; + } + static constexpr WiFiManagerPortalText progmem(const char* value) { + return {value, WiFiManagerPortalStorage::Progmem}; + } + + bool empty() const { return data == nullptr || length() == 0; } + size_t length() const { + return data == nullptr ? 0 : (storage == WiFiManagerPortalStorage::Progmem ? strlen_P(data) : strlen(data)); + } + char at(size_t index) const { + return storage == WiFiManagerPortalStorage::Progmem + ? static_cast(pgm_read_byte(data + index)) + : data[index]; + } +}; + +/** Optional inline SVG branding asset. It is not a general HTML extension point. */ +struct WiFiManagerPortalAsset { + WiFiManagerPortalText svg; + + static constexpr WiFiManagerPortalAsset svgFromRam(const char* value) { + return {WiFiManagerPortalText::ram(value)}; + } + static constexpr WiFiManagerPortalAsset svgFromProgmem(const char* value) { + return {WiFiManagerPortalText::progmem(value)}; + } + + bool empty() const { return svg.empty(); } +}; + +/** + * Semantic colour and shape values for WiFiManager's built-in portal. + * + * Leave a value empty to retain the built-in stylesheet value. Values are + * validated before they are emitted into the portal stylesheet; they are not + * a general CSS injection mechanism. + */ +struct WiFiManagerPortalTheme { + WiFiManagerPortalText pageBackground; + WiFiManagerPortalText surface; + WiFiManagerPortalText text; + WiFiManagerPortalText mutedText; + WiFiManagerPortalText border; + WiFiManagerPortalText accent; + WiFiManagerPortalText accentHover; + WiFiManagerPortalText accentText; + WiFiManagerPortalText danger; + WiFiManagerPortalText dangerHover; + WiFiManagerPortalText success; + uint8_t cornerRadiusPx = 0; + uint8_t smallCornerRadiusPx = 0; +}; + +/** + * Complete setup-time presentation configuration for WiFiManager's portal. + * + * Text and SVG assets are non-owning static data in RAM or PROGMEM. Apply this + * before starting a portal; asynchronous portal responses use immutable state. + */ +struct WiFiManagerPortalConfig { + WiFiManagerPortalText title; + WiFiManagerPortalText identityText; + WiFiManagerPortalText homeIntro; + WiFiManagerPortalAsset logo; + WiFiManagerPortalText logoAltText; + WiFiManagerPortalTheme theme; +}; + +#endif // WIFI_MANAGER_PORTAL_UI_H diff --git a/lib/WiFiManager/include/templates/CSS.h b/lib/WiFiManager/include/templates/CSS.h index 4dce478..9b0676f 100644 --- a/lib/WiFiManager/include/templates/CSS.h +++ b/lib/WiFiManager/include/templates/CSS.h @@ -23,6 +23,7 @@ const char CSS_STYLE[] PROGMEM = ""); } -void WiFiManager::portalSetBrandLogoSvg(const String& svgMarkup) { - _portalBrand.logoSvg = svgMarkup; +bool WiFiManager::setPortalConfig(const WiFiManagerPortalConfig& config) { + if (!canChangePortalPresentation() || !isPortalThemeValid(config.theme)) { + return false; + } + _portalBrand.title = config.title.empty() + ? WiFiManagerPortalText::progmem(kDefaultPortalTitle) + : config.title; + _portalBrand.identityTextOverride = config.identityText; + _portalBrand.homeIntro = config.homeIntro; + _portalBrand.logo = config.logo; + _portalBrand.logoAltText = config.logoAltText; + _portalTheme = config.theme; + rebuildPortalThemeStyle(); + return true; } void WiFiManager::portalSetPageInfoVisible(bool visible) { @@ -2587,6 +2655,7 @@ void WiFiManager::portalSetPageSetupVisible(bool visible) { _portalPages.setupVisible = visible; } + void WiFiManager::portalSetActionEraseVisible(bool visible) { _portalActions.eraseVisible = visible; } @@ -2691,17 +2760,6 @@ void WiFiManager::portalClearHomeCards() { _portalStructured.homeCards.clear(); } -void WiFiManager::portalAppendCss(const String& css) { - _portalAssets.appendedCss += css; -} - -void WiFiManager::portalOverrideCss(const String& css) { - _portalAssets.overriddenCss = css; -} - -void WiFiManager::portalAppendJs(const String& js) { - _portalAssets.appendedJs += js; -} /** * check if the config portal is running diff --git a/lib/WiFiManager/src/WiFiManagerHandlers.cpp b/lib/WiFiManager/src/WiFiManagerHandlers.cpp index 4a8604d..c0fd281 100644 --- a/lib/WiFiManager/src/WiFiManagerHandlers.cpp +++ b/lib/WiFiManager/src/WiFiManagerHandlers.cpp @@ -37,8 +37,25 @@ static void jsonAppendEscaped(String& out, const String& s) { } } +static void jsonAppendEscaped(String& out, const WiFiManagerPortalText& text) { + for (size_t i = 0; i < text.length(); ++i) { + const char c = text.at(i); + if (c == '"') { + out += F("\\\""); + } else if (c == '\\') { + out += F("\\\\"); + } else if (c == '\n') { + out += F("\\n"); + } else if (c != '\r') { + out += c; + } + } +} + namespace { +const char kEmptyPortalPlaceholder[] PROGMEM = ""; + inline void reservePage(String& page, size_t extraBytes = WM_PAGE_RESERVE_BYTES) { if (extraBytes == 0) return; const size_t targetLen = page.length() + extraBytes; @@ -60,24 +77,26 @@ void configureDynamicStringDescriptor(DynamicDataDescriptor& descriptor, String& descriptor.userData = &value; } -String readProgmemString(const char* p) { - return String(FPSTR(p)); +const char* portalTextGetter(void* userData) { + const auto* text = static_cast(userData); + return text && text->storage == WiFiManagerPortalStorage::Ram && text->data + ? text->data + : ""; } -String stripStyleWrapper(const String& styleBlock) { - String css = styleBlock; - const String openTag = F(""); - - if (css.startsWith(openTag)) { - css.remove(0, openTag.length()); - } - if (css.endsWith(closeTag)) { - css.remove(css.length() - closeTag.length()); - } - return css; +size_t portalTextLengthGetter(const char* /*data*/, void* userData) { + const auto* text = static_cast(userData); + return text ? text->length() : 0; } +void configurePortalTextDescriptor(DynamicDataDescriptor& descriptor, + const WiFiManagerPortalText& text) { + descriptor.getter = &portalTextGetter; + descriptor.getLength = &portalTextLengthGetter; + descriptor.userData = const_cast(&text); +} + + const char* portalHomeCardKindJson(PortalHomeCardKind k) { switch (k) { case PortalHomeCardKind::Text: @@ -110,31 +129,27 @@ AsyncWebServerResponse* beginTemplateResponse(AsyncWebServerRequest* request, ); } -// Shell contract (must match templates/RootShell.h — customize UI via WiFiManager portal* APIs + bootstrap JSON, not registries): +// Shell contract (must match templates/RootShell.h): // %PAGE_TITLE% -> document title (from WiFiManager title state) -// %STYLES% -> portal CSS (embedded default + portalAppendCss / portalOverrideCss) +// %STYLES% -> immutable built-in portal stylesheet in PROGMEM +// %PORTAL_THEME% -> optional validated semantic-token override // %BOOTSTRAP_JSON% -> initial SPA runtime payload // %PORTAL_APP_JS% -> embedded SPA source -// %PORTAL_APPEND_JS% -> consumer hook script (portalAppendJs) struct PortalShellRenderBundle { String bootstrapJson; - String pageTitleStatic; - String stylesStatic; - String appendJsStatic; + WiFiManagerPortalText pageTitle; PlaceholderRegistry registry; TemplateContext context; DynamicDataDescriptor bootstrapDescriptor; DynamicDataDescriptor pageTitleDescriptor; - DynamicDataDescriptor stylesDescriptor; - DynamicDataDescriptor appendJsDescriptor; + DynamicDataDescriptor themeDescriptor; PortalShellRenderBundle() : registry(WM_TEMPLATE_REGISTRY_CAPACITY), bootstrapDescriptor{}, pageTitleDescriptor{}, - stylesDescriptor{}, - appendJsDescriptor{} {} + themeDescriptor{} {} }; // Custom HTML attribute strings (e.g. from DeviceFramework generateCustomHTML) may include @@ -150,24 +165,6 @@ bool portalCustomAttrsIndicatePassword(const String& customAttrs) { WiFiManagerHandlers::WiFiManagerHandlers(WiFiManager* wm) : _wm(wm) {} -String WiFiManagerHandlers::composePortalStylesheet() const { - String css; - if (_wm != nullptr && _wm->_portalAssets.overriddenCss.length() > 0) { - css = _wm->_portalAssets.overriddenCss; - } else { - css = stripStyleWrapper(readProgmemString(CSS_STYLE)); - } - - if (_wm != nullptr && _wm->_portalAssets.appendedCss.length() > 0) { - css += _wm->_portalAssets.appendedCss; - } - - String out = F(""); - return out; -} - void WiFiManagerHandlers::appendPortalExtraInfoSectionsJson(String& json, bool& first) { if (_wm == nullptr) { return; @@ -199,7 +196,6 @@ void WiFiManagerHandlers::appendPortalExtraInfoSectionsJson(String& json, bool& json += F("]}"); } } - void WiFiManagerHandlers::appendPortalExtraHomeCardsJson(String& json, bool& first) { if (_wm == nullptr) { return; @@ -686,25 +682,24 @@ void WiFiManagerHandlers::handleRoot(AsyncWebServerRequest *request) { // - WiFiManagerHandlers owns request-time shell assembly; WiFiManagerServer owns HTTP lifecycle only. // - Build one request-scoped PortalShellRenderBundle. // - Populate one request-scoped PlaceholderRegistry with shell defaults + request payloads. - // - Render WM_ROOT_SHELL_TEMPLATE. + // - Render WM_ROOT_SHELL_TEMPLATE with static CSS plus an optional small theme block. auto bundle = std::make_shared(); - const bool hasCustomStyles = _wm != nullptr && - (_wm->_portalAssets.overriddenCss.length() > 0 || _wm->_portalAssets.appendedCss.length() > 0); - bundle->pageTitleStatic = _wm ? _wm->_portalBrand.title : String(); + bundle->pageTitle = _wm ? _wm->_portalBrand.title : WiFiManagerPortalText{}; bundle->bootstrapJson = buildPortalBootstrapJson(); - bundle->appendJsStatic = _wm ? _wm->_portalAssets.appendedJs : String(); + bundle->registry.registerProgmemData("%STYLES%", CSS_STYLE); bundle->registry.registerProgmemData("%PORTAL_APP_JS%", PORTAL_APP_JS); - if (hasCustomStyles) { - bundle->stylesStatic = composePortalStylesheet(); - configureDynamicStringDescriptor(bundle->stylesDescriptor, bundle->stylesStatic); - bundle->registry.registerDynamicData("%STYLES%", &bundle->stylesDescriptor); + if (_wm) { + configureDynamicStringDescriptor(bundle->themeDescriptor, _wm->_portalThemeStyle); + bundle->registry.registerDynamicData("%PORTAL_THEME%", &bundle->themeDescriptor); } else { - bundle->registry.registerProgmemData("%STYLES%", CSS_STYLE); + bundle->registry.registerProgmemData("%PORTAL_THEME%", kEmptyPortalPlaceholder); + } + if (bundle->pageTitle.storage == WiFiManagerPortalStorage::Progmem && bundle->pageTitle.data) { + bundle->registry.registerProgmemData("%PAGE_TITLE%", bundle->pageTitle.data); + } else { + configurePortalTextDescriptor(bundle->pageTitleDescriptor, bundle->pageTitle); + bundle->registry.registerDynamicData("%PAGE_TITLE%", &bundle->pageTitleDescriptor); } - configureDynamicStringDescriptor(bundle->appendJsDescriptor, bundle->appendJsStatic); - bundle->registry.registerDynamicData("%PORTAL_APPEND_JS%", &bundle->appendJsDescriptor); - configureDynamicStringDescriptor(bundle->pageTitleDescriptor, bundle->pageTitleStatic); - bundle->registry.registerDynamicData("%PAGE_TITLE%", &bundle->pageTitleDescriptor); configureDynamicStringDescriptor(bundle->bootstrapDescriptor, bundle->bootstrapJson); bundle->registry.registerDynamicData("%BOOTSTRAP_JSON%", &bundle->bootstrapDescriptor); @@ -1076,7 +1071,9 @@ String WiFiManagerHandlers::buildPortalBootstrapJson() { json += F("\",\"homeIntro\":\""); jsonAppendEscaped(json, _wm->_portalBrand.homeIntro); json += F("\",\"logoSvg\":\""); - jsonAppendEscaped(json, _wm->_portalBrand.logoSvg); + jsonAppendEscaped(json, _wm->_portalBrand.logo.svg); + json += F("\",\"logoAltText\":\""); + jsonAppendEscaped(json, _wm->_portalBrand.logoAltText); json += F("\"}"); json += F(",\"context\":{"); json += F("\"portalActive\":"); @@ -1094,15 +1091,12 @@ String WiFiManagerHandlers::buildPortalBootstrapJson() { json += String(timeoutRemainingSeconds); } json += F(",\"identityText\":\""); - { - String idText; - if (_wm->_portalBrand.identityTextOverride.length() > 0) { - idText = _wm->_portalBrand.identityTextOverride; - } else if (_wm->configPortalActive) { - idText = _wm->_apName; - } else { - idText = _wm->getWiFiHostname() + " - " + WiFi.localIP().toString(); - } + if (!_wm->_portalBrand.identityTextOverride.empty()) { + jsonAppendEscaped(json, _wm->_portalBrand.identityTextOverride); + } else { + const String idText = _wm->configPortalActive + ? _wm->_apName + : _wm->getWiFiHostname() + " - " + WiFi.localIP().toString(); jsonAppendEscaped(json, idText); } json += F("\",\"statusSummary\":\""); diff --git a/library.json b/library.json index 6612600..fed23e6 100644 --- a/library.json +++ b/library.json @@ -47,18 +47,18 @@ { "owner": "esp32async", "name": "ESPAsyncTCP", - "version": "2.0.0", + "version": "^2.0.0", "platforms": "espressif8266" }, { "owner": "esp32async", "name": "AsyncTCP", - "version": "3.4.9", + "version": "^3.4.9", "platforms": "espressif32" }, { "name": "DeviceFrameworkTemplateEngine", - "version": "https://github.com/alexhopeoconnor/DFTE.git#v1.0.2" + "version": "https://github.com/alexhopeoconnor/DFTE.git#v1.1.0" } ], "build": { diff --git a/platformio.ini b/platformio.ini index 947ef85..cf9fe5f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -16,11 +16,11 @@ build_flags = -DUNIT_TEST lib_deps = ESP32Async/ESPAsyncWebServer@3.9.1 - DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.0.2 + DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.1.0 ESP32Async/ESPAsyncTCP@2.0.0 [env:esp32] -platform = espressif32@6.13.0 +platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip board = esp32dev framework = arduino monitor_speed = 115200 @@ -30,12 +30,14 @@ build_unflags = -std=gnu++11 build_flags = -std=gnu++14 + -DSOC_WIFI_SUPPORTED=1 + -I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src -DWM_LOG_LEVEL=5 -DUNIT_TEST lib_deps = ESP32Async/ESPAsyncWebServer@3.9.1 - DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.0.2 - ESP32Async/AsyncTCP@3.4.9 + DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.1.0 + ESP32Async/AsyncTCP@^3.4.9 ; Optional: compile tests with DFTE logs bridged into WiFiManager::log (see README) [env:esp8266_dfte_log] diff --git a/scripts/check-docs.sh b/scripts/check-docs.sh index e7b7003..fc54ab6 100755 --- a/scripts/check-docs.sh +++ b/scripts/check-docs.sh @@ -35,7 +35,7 @@ while IFS= read -r file; do done < "$file" done < <(find "$root" -path "$root/.git" -prune -o -path '*/.pio' -prune -o -type f -name '*.md' -print) -for required in README.md CHANGELOG.md docs/README.md docs/GETTING_STARTED.md docs/PORTAL_CUSTOMIZATION.md docs/PORTAL_API.md docs/TESTING.md docs/DEVELOPMENT.md; do +for required in README.md CHANGELOG.md docs/README.md docs/GETTING_STARTED.md docs/PORTAL_UI.md docs/PORTAL_API.md docs/TESTING.md docs/DEVELOPMENT.md; do if [[ ! -f "$root/$required" ]]; then printf 'Missing required documentation file: %s\n' "$required" >&2 failed=1 diff --git a/scripts/test.sh b/scripts/test.sh index 2291382..da9f6ce 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -11,6 +11,13 @@ platform="${3:-}" [[ "$platform" == "esp8266" || "$platform" == "esp32" ]] || usage root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cached_library="$root/test/compile-project/.pio/libdeps/${platform}/WiFiManager" +# The fixture intentionally declares only this local package. Remove a prior +# link so each check resolves the current manifest as a fresh consumer would. +if [[ -d "$cached_library" || -e "${cached_library}.pio-link" ]]; then + pio pkg uninstall -d "$root/test/compile-project" -e "$platform" \ + -l WiFiManager --no-save --skip-dependencies >/dev/null +fi pio run -d "$root/test/compile-project" -e "$platform" echo "WiFiManager consumer compile check passed for $platform" diff --git a/test/compile-project/platformio.ini b/test/compile-project/platformio.ini index 0cdff75..3c6f773 100644 --- a/test/compile-project/platformio.ini +++ b/test/compile-project/platformio.ini @@ -20,11 +20,13 @@ lib_deps = [env:esp32] extends = common -platform = espressif32@6.13.0 +platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip board = esp32dev build_unflags = -std=gnu++11 build_flags = -std=gnu++14 + -DSOC_WIFI_SUPPORTED=1 + -I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src -DWM_LOG_LEVEL=3 lib_deps = ${common.lib_deps} diff --git a/test/compile-project/src/main.cpp b/test/compile-project/src/main.cpp index 2dd1f85..9f5f20f 100644 --- a/test/compile-project/src/main.cpp +++ b/test/compile-project/src/main.cpp @@ -1,9 +1,45 @@ #include #include +namespace { +const char kTitle[] PROGMEM = "Set up Compile Fixture"; +const char kIdentity[] PROGMEM = "WiFiManager"; +const char kIntro[] PROGMEM = "A branded portal compile check."; +const char kLogoAlt[] PROGMEM = "WiFiManager"; +const char kLogo[] PROGMEM = ""; +const char kPage[] PROGMEM = "#f4f7f3"; +const char kSurface[] PROGMEM = "#ffffff"; +const char kText[] PROGMEM = "#1c251e"; +const char kMuted[] PROGMEM = "#607064"; +const char kBorder[] PROGMEM = "#d6e0d7"; +const char kAccent[] PROGMEM = "#347a45"; +const char kAccentHover[] PROGMEM = "#245a32"; +const char kAccentText[] PROGMEM = "#ffffff"; + +const WiFiManagerPortalConfig kPortalConfig = { + WiFiManagerPortalText::progmem(kTitle), + WiFiManagerPortalText::progmem(kIdentity), + WiFiManagerPortalText::progmem(kIntro), + WiFiManagerPortalAsset::svgFromProgmem(kLogo), + WiFiManagerPortalText::progmem(kLogoAlt), + { + WiFiManagerPortalText::progmem(kPage), + WiFiManagerPortalText::progmem(kSurface), + WiFiManagerPortalText::progmem(kText), + WiFiManagerPortalText::progmem(kMuted), + WiFiManagerPortalText::progmem(kBorder), + WiFiManagerPortalText::progmem(kAccent), + WiFiManagerPortalText::progmem(kAccentHover), + WiFiManagerPortalText::progmem(kAccentText), + {}, {}, {}, 10, 6, + }, +}; +} // namespace + WiFiManager wifiManager; void setup() { + wifiManager.setPortalConfig(kPortalConfig); wifiManager.setConfigPortalTimeout(1); } diff --git a/test/test_wifimanager/test_main.cpp b/test/test_wifimanager/test_main.cpp index 803b8cc..360c6c7 100644 --- a/test/test_wifimanager/test_main.cpp +++ b/test/test_wifimanager/test_main.cpp @@ -36,9 +36,11 @@ TestCase tests[] = { TEST_ENTRY(test_config_portal_already_active), TEST_ENTRY(test_get_config_portal_ssid), TEST_ENTRY(test_bootstrap_json_portal_feature_flags), + TEST_ENTRY(test_portal_default_presentation), TEST_ENTRY(test_bootstrap_json_contract_v2), TEST_ENTRY(test_bootstrap_json_snapshot_consistency), TEST_ENTRY(test_root_render_interleaved_context_isolation), + TEST_ENTRY(test_portal_presentation_configuration), // Non-blocking tests TEST_ENTRY(test_nonblocking_process), @@ -104,7 +106,7 @@ TestCase tests[] = { // Template rendering tests TEST_ENTRY(test_shell_template_renders_core_placeholders), - TEST_ENTRY(test_shell_template_renders_dynamic_styles_with_percent_values), + TEST_ENTRY(test_shell_template_renders_dynamic_theme_with_percent_values), // State transition tests TEST_ENTRY(test_portal_to_connected_transition), diff --git a/test/test_wifimanager/test_main.h b/test/test_wifimanager/test_main.h index 223d299..9670eab 100644 --- a/test/test_wifimanager/test_main.h +++ b/test/test_wifimanager/test_main.h @@ -38,9 +38,11 @@ void test_config_portal_multiple_start_stop(); void test_config_portal_already_active(); void test_get_config_portal_ssid(); void test_bootstrap_json_portal_feature_flags(); +void test_portal_default_presentation(); void test_bootstrap_json_contract_v2(); void test_bootstrap_json_snapshot_consistency(); void test_root_render_interleaved_context_isolation(); +void test_portal_presentation_configuration(); // Non-blocking tests void test_nonblocking_process(); @@ -108,7 +110,7 @@ void test_scan_generation_invalidated_on_reset(); // Template rendering tests void test_shell_template_renders_core_placeholders(); -void test_shell_template_renders_dynamic_styles_with_percent_values(); +void test_shell_template_renders_dynamic_theme_with_percent_values(); // API JSON + captive helper tests void test_captive_redirect_host_rule(); diff --git a/test/test_wifimanager/tests/test_advanced_options.cpp b/test/test_wifimanager/tests/test_advanced_options.cpp index 6962483..de1b821 100644 --- a/test/test_wifimanager/tests/test_advanced_options.cpp +++ b/test/test_wifimanager/tests/test_advanced_options.cpp @@ -88,8 +88,10 @@ void test_ui_customization() { WiFiManager wm; - // Minimal customization retained: title - wm.portalSetBrandTitle("MyDevice"); + // Minimal customization uses the complete static portal configuration. + WiFiManagerPortalConfig portal; + portal.title = WiFiManagerPortalText::ram("MyDevice"); + TEST_ASSERT_TRUE(wm.setPortalConfig(portal)); // All setters executed without crash TEST_ASSERT_TRUE_MESSAGE(true, "UI customization options executed without crash"); diff --git a/test/test_wifimanager/tests/test_configuration.cpp b/test/test_wifimanager/tests/test_configuration.cpp index 48ebf33..709fc50 100644 --- a/test/test_wifimanager/tests/test_configuration.cpp +++ b/test/test_wifimanager/tests/test_configuration.cpp @@ -39,8 +39,10 @@ void test_configuration_setters() { wm.portalSetBehaviorCaptivePortalEnabled(true); wm.portalSetBehaviorCaptivePortalEnabled(false); - // Test custom title path - wm.portalSetBrandTitle("TestTitle"); + // Test the typed portal presentation path. + WiFiManagerPortalConfig portal; + portal.title = WiFiManagerPortalText::ram("TestTitle"); + TEST_ASSERT_TRUE(wm.setPortalConfig(portal)); // Call setters multiple times with different values. wm.setConfigPortalTimeout(60); diff --git a/test/test_wifimanager/tests/test_root_render_lifecycle.cpp b/test/test_wifimanager/tests/test_root_render_lifecycle.cpp index d664d70..def02e5 100644 --- a/test/test_wifimanager/tests/test_root_render_lifecycle.cpp +++ b/test/test_wifimanager/tests/test_root_render_lifecycle.cpp @@ -76,16 +76,29 @@ void test_bootstrap_json_portal_feature_flags() { Serial.println("[TEST] Bootstrap portal feature flags test completed successfully"); } +void test_portal_default_presentation() { + WiFiManager wm; + WiFiManagerHandlers handlers(&wm); + + const String bootstrap = handlers.buildPortalBootstrapJson(); + TEST_ASSERT_NOT_EQUAL(-1, bootstrap.indexOf(F("\"title\":\"WiFiManager\""))); + TEST_ASSERT_NOT_EQUAL(-1, bootstrap.indexOf(F("\"homeIntro\":\"\""))); + TEST_ASSERT_NOT_EQUAL(-1, bootstrap.indexOf(F("\"logoSvg\":\"\""))); + TEST_ASSERT_NOT_EQUAL(-1, bootstrap.indexOf(F("\"logoAltText\":\"\""))); +} + void test_bootstrap_json_contract_v2() { Serial.println("[TEST] Testing bootstrap JSON v2 contract..."); WiFiManager wm; WiFiManagerHandlers handlers(&wm); - wm.portalSetBrandTitle("Solar Battery Monitor Setup"); - wm.portalSetContextIdentityText("Solar Battery Monitor"); - wm.portalSetBrandHomeIntro("Connect this device to WiFi and finish setup."); - wm.portalSetBrandLogoSvg(""); + WiFiManagerPortalConfig portal; + portal.title = WiFiManagerPortalText::ram("Solar Battery Monitor Setup"); + portal.identityText = WiFiManagerPortalText::ram("Solar Battery Monitor"); + portal.homeIntro = WiFiManagerPortalText::ram("Connect this device to WiFi and finish setup."); + portal.logo = WiFiManagerPortalAsset::svgFromRam(""); + TEST_ASSERT_TRUE(wm.setPortalConfig(portal)); wm.portalSetPageInfoVisible(true); wm.portalSetPageUpdateVisible(false); wm.portalSetPageSetupVisible(true); @@ -163,11 +176,11 @@ void test_root_render_interleaved_context_isolation() { registryA.registerProgmemData("%STYLES%", kEmptyTemplateChunk); registryA.registerProgmemData("%PAGE_TITLE%", kTestTitle); - registryA.registerProgmemData("%PORTAL_APPEND_JS%", kEmptyTemplateChunk); + registryA.registerProgmemData("%PORTAL_THEME%", kEmptyTemplateChunk); registryB.registerProgmemData("%STYLES%", kEmptyTemplateChunk); registryB.registerProgmemData("%PAGE_TITLE%", kTestTitle); - registryB.registerProgmemData("%PORTAL_APPEND_JS%", kEmptyTemplateChunk); + registryB.registerProgmemData("%PORTAL_THEME%", kEmptyTemplateChunk); String bootstrapA = F("{\"ctx\":\"A\"}"); String appJsA = F("// shell A"); @@ -217,3 +230,51 @@ void test_root_render_interleaved_context_isolation() { Serial.println("[TEST] Root render interleaved context isolation test completed successfully"); } + +void test_portal_presentation_configuration() { + Serial.println("[TEST] Testing portal presentation configuration..."); + + WiFiManager wm; + WiFiManagerHandlers handlers(&wm); + WiFiManagerPortalConfig config; + config.title = WiFiManagerPortalText::ram("Set up Temperature Monitor"); + config.identityText = WiFiManagerPortalText::ram("Tree"); + config.homeIntro = WiFiManagerPortalText::ram("Connect this device to WiFi."); + config.logo = WiFiManagerPortalAsset::svgFromRam(""); + config.logoAltText = WiFiManagerPortalText::ram("Tree logo"); + config.theme.pageBackground = WiFiManagerPortalText::ram("#f4f7f3"); + config.theme.surface = WiFiManagerPortalText::ram("#ffffff"); + config.theme.text = WiFiManagerPortalText::ram("#1c251e"); + config.theme.mutedText = WiFiManagerPortalText::ram("#607064"); + config.theme.border = WiFiManagerPortalText::ram("#d6e0d7"); + config.theme.accent = WiFiManagerPortalText::ram("#347a45"); + config.theme.accentHover = WiFiManagerPortalText::ram("#245a32"); + config.theme.accentText = WiFiManagerPortalText::ram("#ffffff"); + config.theme.success = WiFiManagerPortalText::ram("#2f855a"); + config.theme.danger = WiFiManagerPortalText::ram("#c53030"); + config.theme.dangerHover = WiFiManagerPortalText::ram("#9b2c2c"); + config.theme.cornerRadiusPx = 10; + config.theme.smallCornerRadiusPx = 6; + + TEST_ASSERT_TRUE_MESSAGE(wm.setPortalConfig(config), + "A complete setup-time portal configuration should be accepted"); + + String bootstrap = handlers.buildPortalBootstrapJson(); + TEST_ASSERT_NOT_EQUAL(-1, bootstrap.indexOf(F("\"title\":\"Set up Temperature Monitor\""))); + TEST_ASSERT_NOT_EQUAL(-1, bootstrap.indexOf(F("\"identityText\":\"Tree\""))); + TEST_ASSERT_NOT_EQUAL(-1, bootstrap.indexOf(F("\"logoAltText\":\"Tree logo\""))); + + WiFiManagerPortalConfig unsafeConfig = config; + unsafeConfig.theme.accent = WiFiManagerPortalText::ram("#347a45;body{display:none}"); + TEST_ASSERT_FALSE_MESSAGE(wm.setPortalConfig(unsafeConfig), + "Semantic theme values must reject stylesheet injection characters"); + + wm.startWebPortal(); + TEST_ASSERT_FALSE_MESSAGE(wm.setPortalConfig(config), + "Portal presentation must remain immutable while async routes are active"); + wm.stopWebPortal(); + TEST_ASSERT_TRUE_MESSAGE(wm.setPortalConfig(config), + "Presentation can be applied after the portal stops"); + + Serial.println("[TEST] Portal presentation configuration test completed successfully"); +} diff --git a/test/test_wifimanager/tests/test_template_rendering.cpp b/test/test_wifimanager/tests/test_template_rendering.cpp index b128be5..d47e33f 100644 --- a/test/test_wifimanager/tests/test_template_rendering.cpp +++ b/test/test_wifimanager/tests/test_template_rendering.cpp @@ -28,7 +28,7 @@ const char kEmpty[] PROGMEM = ""; const char kDocTitle[] PROGMEM = "Config ESP"; const char kBootstrapJson[] PROGMEM = "{\"title\":\"Test\"}"; const char kPortalAppJs[] PROGMEM = "console.log('portal');"; -const char kPortalAppendJs[] PROGMEM = ""; +const char kPortalTheme[] PROGMEM = ""; const char* dynamicStringGetter(void* userData) { const auto* value = static_cast(userData); @@ -55,7 +55,7 @@ void test_shell_template_renders_core_placeholders() { registry.registerProgmemData("%PAGE_TITLE%", kDocTitle); registry.registerProgmemData("%BOOTSTRAP_JSON%", kBootstrapJson); registry.registerProgmemData("%PORTAL_APP_JS%", kPortalAppJs); - registry.registerProgmemData("%PORTAL_APPEND_JS%", kPortalAppendJs); + registry.registerProgmemData("%PORTAL_THEME%", kPortalTheme); String output = renderTemplate(WM_ROOT_SHELL_TEMPLATE, registry); @@ -70,35 +70,32 @@ void test_shell_template_renders_core_placeholders() { Serial.println("[TEST] Portal shell template rendering test completed successfully"); } -void test_shell_template_renders_dynamic_styles_with_percent_values() { - Serial.println("[TEST] Testing portal shell dynamic styles placeholder with percent values..."); +void test_shell_template_renders_dynamic_theme_with_percent_values() { + Serial.println("[TEST] Testing portal shell dynamic theme placeholder with percent values..."); PlaceholderRegistry registry(8); - String styles = F(""); + String theme = F(""); String title = F("Config ESP"); String bootstrap = F("{\"title\":\"Test\"}"); - String appendJs = F(""); - DynamicDataDescriptor stylesDescriptor{}; + DynamicDataDescriptor themeDescriptor{}; DynamicDataDescriptor titleDescriptor{}; DynamicDataDescriptor bootstrapDescriptor{}; - DynamicDataDescriptor appendJsDescriptor{}; - configureDescriptor(stylesDescriptor, styles); + configureDescriptor(themeDescriptor, theme); configureDescriptor(titleDescriptor, title); configureDescriptor(bootstrapDescriptor, bootstrap); - configureDescriptor(appendJsDescriptor, appendJs); - TEST_ASSERT_TRUE(registry.registerDynamicData("%STYLES%", &stylesDescriptor)); + TEST_ASSERT_TRUE(registry.registerProgmemData("%STYLES%", kEmpty)); + TEST_ASSERT_TRUE(registry.registerDynamicData("%PORTAL_THEME%", &themeDescriptor)); TEST_ASSERT_TRUE(registry.registerDynamicData("%PAGE_TITLE%", &titleDescriptor)); TEST_ASSERT_TRUE(registry.registerDynamicData("%BOOTSTRAP_JSON%", &bootstrapDescriptor)); TEST_ASSERT_TRUE(registry.registerProgmemData("%PORTAL_APP_JS%", kPortalAppJs)); - TEST_ASSERT_TRUE(registry.registerDynamicData("%PORTAL_APPEND_JS%", &appendJsDescriptor)); String output = renderTemplate(WM_ROOT_SHELL_TEMPLATE, registry); - TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("")); + TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("")); TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("Config ESP")); TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("{\"title\":\"Test\"}")); - Serial.println("[TEST] Dynamic styles placeholder percent rendering test completed successfully"); + Serial.println("[TEST] Dynamic theme placeholder percent rendering test completed successfully"); }