diff --git a/.gitignore b/.gitignore index ae095a1..9a3c7f1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,414 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# Files built by +/sch/*.b#* +/sch/*.pro +/sch/*.s#* + + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ +__vm/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc +*.vcxproj +*.filters +*.user + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace .vscode -examples-pio \ No newline at end of file +examples-pio + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +*.heic + +/IotWebConf.sln +/IotWebConf.vcxitems diff --git a/README.md b/README.md index 70ee741..815cc18 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,23 @@ # IotWebConf [![Build Status](https://github.com/prampec/IotWebConf/workflows/PlatformIO%20CI/badge.svg?branch=master)](https://github.com/prampec/IotWebConf/actions/workflows/test.platformio.yml) +## Table of contents +- [Upgrading to v3.0.0](#upgrading-to-v300) +- [Summary](#summary) +- [Highlights](#highlights) +- [How it works](#how-it-works) +- [Use cases](#use-cases) +- [User notes](#user-notes) +- [IotWebConf vs. WiFiManager](#iotwebconf-vs-wifimanager) +- [Security aspects](#security-aspects) +- [Compatibility](#compatibility) +- [ESPAsyncWebServer support](#espasyncwebserver-support) +- [Tab support](#tab-support) +- [Input parameters](#input-parameters) +- [Customizing and extending functionality](#customizing-and-extending-functionality) +- [TODO / Feature requests](#todo--feature-requests) +- [Known issues](#known-issues) +- [Credits](#credits) + ## Upgrading to v3.0.0 Lately version 3.0.0 is released. This release is not backward compatible with older versions, and some modification have to be done on existing codes. @@ -89,6 +107,325 @@ to ESP32. There are two major problems. - ESP8266 uses specific naming for it's classes (e.g. ESP8266WebServer). However, ESP32 uses a more generic naming (e.g. WebServer). The idea here is to use the generic naming hoping that ESP8266 will adopt these "standards" sooner or later. - ESP32 does not provide an HTTPUpdateServer implementation. So in this project we have implemented one. Whenever ESP32 provides an official HTTPUpdateServer, this local implementation will be removed. +## ESPAsyncWebServer support +This library targets the synchronous web server implementations used by ESP8266/ESP32 Arduino cores. + +If your project is based on **ESPAsyncWebServer**, please use the dedicated async extension: + - [minou65/IotWebConfAsync](https://github.com/minou65/IotWebConfAsync) + +That project provides IotWebConf functionality adapted to async server handling and is the recommended way to use IotWebConf concepts with ESPAsyncWebServer. + +## Tab support +IotWebConf also includes tab-based configuration page support via `IotWebConfTab`. +With tabs you can split large configuration forms into smaller, easier-to-navigate sections. + +### Basic usage +1. Include `IotWebConfTab.h` instead of `IotWebConf.h`. +1. Create your `IotWebConfTab` instance. +1. Assign each `ParameterGroup` to a tab name. + +```cpp +#include + +DNSServer dnsServer; +WebServer server(80); + +IotWebConfTab iotWebConf("MyThing", &dnsServer, &server, "initial123"); + +ParameterGroup networkGroup("network", "Network"); +ParameterGroup appGroup("app", "Application"); + +// Each group is rendered on the tab specified by name. +iotWebConf.addParameterGroup(&networkGroup, "Network"); +iotWebConf.addParameterGroup(&appGroup, "Application"); +``` + +### Optional tab configuration +- `setSystemTabName("System")` to customize the system tab title. +- `setSystemTabPosition(...)` to place the system tab first, last, or at a specific index. +- `setContainerWidth(minWidth, maxWidth)` to control layout width. + +If you call `addParameterGroup(group)` without a tab name, the group is added to a default tab named `Configuration`. + +## Input parameters + +IotWebConf provides various parameter types for creating configuration forms. Each parameter type is designed for specific use cases and renders appropriate HTML input fields. + +### Base Classes + +#### ConfigItem +Abstract base class for all configuration items. Provides core functionality for: +- EEPROM storage and loading +- HTML rendering +- Value validation +- Debug output +- JSON serialization (if enabled) + +#### ParameterGroup +Groups related parameters together in fieldsets for better organization. + +```cpp +ParameterGroup group("groupId", "Group Label"); +``` + +#### Parameter +Base class for all input parameters with common properties: +- `label` - Display label in the configuration form +- `id` - Unique identifier for HTML and storage +- `valueBuffer` - Buffer to store the parameter value +- `length` - Buffer length +- `defaultValue` - Default value on first startup +- `errorMessage` - Validation error message +- `visible` - Controls parameter visibility + +### Available Parameter Types + +#### TextParameter +Standard text input field for string values. + +```cpp +TextParameter textParam( + "Parameter Label", // label + "paramId", // id + valueBuffer, // value buffer + 32, // buffer length + "default value", // default value (optional) + "placeholder text", // placeholder (optional) + "custom HTML attrs" // custom HTML attributes (optional) +); +``` + +**Properties:** +- `placeholder` - Text displayed in empty input field +- `customHtml` - Additional HTML attributes for customization + +#### PasswordParameter +Password input field with special handling - only updates EEPROM when a new value is provided. + +```cpp +PasswordParameter passwordParam( + "Password", + "password", + passwordBuffer, + 32, + "defaultPass", // default value (optional) + "Enter password", // placeholder (optional) + "ondblclick=\"pw(this.id)\"" // custom HTML (optional) +); +``` + +**Features:** +- Masked input field (type="password") +- Double-click to reveal password (with default customHtml) +- Only saves to EEPROM when new value provided +- Limited to IOTWEBCONF_PASSWORD_LEN characters + +#### NumberParameter +Numeric input field with number validation. + +```cpp +NumberParameter numberParam( + "Port Number", + "port", + portBuffer, + 6, + "8080", // default value (optional) + "Enter port", // placeholder (optional) + "min='1' max='65535'" // custom HTML for validation (optional) +); +``` + +**Features:** +- HTML5 number input type +- Browser-native numeric validation +- Supports min/max attributes via customHtml + +#### CheckboxParameter +Checkbox input for boolean values. + +```cpp +CheckboxParameter checkboxParam( + "Enable Feature", + "enable", + enableBuffer, + 10, + true // default checked state +); +``` + +**Features:** +- Value is either empty or "selected" +- `isChecked()` method returns boolean state +- Special form handling (unchecked boxes don't send values) + +#### SelectParameter +Dropdown selection from predefined options. + +```cpp +// Define options +const char* optionValues = "opt1\0opt2\0opt3"; +const char* optionNames = "Option 1\0Option 2\0Option 3"; + +SelectParameter selectParam( + "Select Option", + "option", + optionBuffer, + 10, + optionValues, // values to store + optionNames, // display names + 3, // option count + 20, // max name length + "opt1" // default value (optional) +); +``` + +**Features:** +- Dropdown HTML SELECT element +- Separate values and display names +- Configurable option count and name length + +#### DateParameter +HTML5 date picker input. + +```cpp +DateParameter dateParam( + "Select Date", + "date", + dateBuffer, + 12, + "2023-01-01" // default value (optional) +); +``` + +**Features:** +- Native browser date picker +- ISO date format (YYYY-MM-DD) +- Calendar popup interface + +#### DayParameter +Multi-select weekday picker with checkboxes. + +```cpp +// 1. Default English (all instances) +char workDaysBuffer[32] = "1,2,3,4,5"; +DayParameter workDays("Working Days", "workdays", workDaysBuffer, 32); + +char weekendBuffer[32] = "6,7"; +DayParameter weekend("Weekend", "weekend", weekendBuffer, 32); + +// 2. Switch to German (applies to ALL DayParameter instances) +const char* germanDaysShort[7] = {"Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"}; +DayParameter::setDayNamesShort(germanDaysShort); + +// Now both workDays AND weekend display German names + +// 3. Single-select mode +char backupDayBuffer[8] = "1"; +DayParameter backupDay("Backup Day", "backupday", backupDayBuffer, 8, "1", true); + +// 4. Runtime query and modification +// Check if a specific day is selected +if (workDays.isDaySelected(1)) { // Monday + Serial.println("Monday is selected"); +} + +// Get all selected days +int selectedDays[7]; +int count = workDays.getSelectedDays(selectedDays); +for (int i = 0; i < count; i++) { + Serial.print("Selected day: "); + Serial.println(selectedDays[i]); // 1-7 +} + +// Set selected days programmatically +int newDays[] = {1, 3, 5}; // Monday, Wednesday, Friday +workDays.setSelectedDays(newDays, 3); +``` + +**Features:** +- Multiple weekday selection with checkboxes +- Static day names (applies to all instances) +- Customizable day names via `setDayNamesShort()` +- Optional single-select mode (radio buttons) +- Days stored as comma-separated numbers (1=Mon, 7=Sun) +- Default English labels (Mon-Sun) + +#### TimeParameter +HTML5 time picker input. + +```cpp +TimeParameter timeParam( + "Select Time", + "time", + timeBuffer, + 8, + "12:00" // default value (optional) +); +``` + +**Features:** +- Native browser time picker +- 24-hour format (HH:MM) +- Time selection interface + +### Parameter Organization + +#### Adding Parameters to Groups +```cpp +ParameterGroup mainGroup("main", "Main Settings"); +mainGroup.addItem(&textParam); +mainGroup.addItem(&passwordParam); +mainGroup.addItem(&numberParam); +``` + +#### Visibility Control +```cpp +// Hide parameter conditionally +textParam.visible = false; +``` + +### Advanced Features + +#### Custom HTML Attributes +All text-based parameters support custom HTML attributes: +```cpp +TextParameter customParam( + "Custom Field", + "custom", + buffer, + 32, + nullptr, + "placeholder", + "required pattern='[A-Za-z]+' title='Letters only'" +); +``` + +#### Validation +Parameters can have error messages set for validation feedback: +```cpp +if (strlen(textParam.valueBuffer) < 3) { + textParam.errorMessage = "Minimum 3 characters required"; +} +``` + +#### JSON Support +When `IOTWEBCONF_ENABLE_JSON` is defined, parameters support JSON serialization: +```cpp +#ifdef IOTWEBCONF_ENABLE_JSON + parameter.loadFromJson(jsonObject); +#endif +``` + +### Best Practices + +1. **Buffer Management**: Always ensure value buffers are large enough for expected values +2. **Parameter IDs**: Use unique, descriptive IDs without spaces or special characters +3. **Default Values**: Provide sensible defaults for better user experience +4. **Validation**: Implement proper validation and error handling +5. **Organization**: Group related parameters together using ParameterGroup +6. **Security**: Use PasswordParameter for sensitive data + + ## Customizing and extending functionality IotWebConf is ment to be developer friendly by providing lots of customization options. See [HackingGuide](doc/HackingGuide.md) for diff --git a/library.properties b/library.properties index 2c50c84..dad4297 100644 --- a/library.properties +++ b/library.properties @@ -1,10 +1,10 @@ name=IotWebConf -version=3.2.1 +version=3.5.1 author=Balazs Kelemen -maintainer=Balazs Kelemen +maintainer=Balazs Kelemen , Andreas Zogg sentence=ESP8266/ESP32 non-blocking WiFi/AP web configuration. paragraph=IotWebConf will start up in AP (access point) mode, and provide a config portal for entering WiFi connection and other user-settings. The configuration is persisted in EEPROM. The config portal will stay available after WiFi connection was made. A WiFiManager alternative. category=Communication -url=https://github.com/prampec/IotWebConf +url=https://github.com/minou65/IotWebConf architectures=esp8266,esp32 includes=IotWebConf.h diff --git a/src/IotWebConf.cpp b/src/IotWebConf.cpp index 4b2b9b7..1bbea74 100644 --- a/src/IotWebConf.cpp +++ b/src/IotWebConf.cpp @@ -247,6 +247,30 @@ void IotWebConf::setConfigSavedCallback(std::function func) this->_configSavedCallback = func; } +void IotWebConf::setConfigAPPasswordMissingPage( + std::function func) +{ + this->_configAPPasswordMissingPage = func; +} + +void IotWebConf::setConfigSSIDNotConfiguredPage( + std::function func) +{ + this->_configSSIDNotConfiguredPage = func; +} + +void IotWebConf::setConfigNotConfiguredPage( + std::function func) +{ + this->_configNotConfiguredPage = func; +} + +void IotWebConf::setConfigSavedPage( + std::function func) +{ + this->_configSavedPage = func; +} + void IotWebConf::setFormValidator( std::function func) { @@ -260,6 +284,21 @@ void IotWebConf::setWifiConnectionTimeoutMs(unsigned long millis) //////////////////////////////////////////////////////////////////////////////// +String IotWebConf::getUpdateLinkHtml() { + if (this->_updatePath != nullptr) { + String pitem = htmlFormatProvider->getUpdate(); + pitem.replace("{u}", this->_updatePath); + return pitem; + } + return String(); +} + +String IotWebConf::getConfigVersionHtml() { + String pitem = htmlFormatProvider->getConfigVer(); + pitem.replace("{v}", this->_configVersion); + return pitem; +} + void IotWebConf::handleConfig(WebRequestWrapper* webRequestWrapper) { if (this->_state == OnLine) @@ -290,7 +329,7 @@ void IotWebConf::handleConfig(WebRequestWrapper* webRequestWrapper) webRequestWrapper->send(200, "text/html; charset=UTF-8", ""); String content = htmlFormatProvider->getHead(); - content.replace("{v}", "Config ESP"); + content.replace("{v}", String("Config ") + this->getThingName()); content += htmlFormatProvider->getScript(); content += htmlFormatProvider->getStyle(); content += htmlFormatProvider->getHeadExtension(); @@ -301,9 +340,9 @@ void IotWebConf::handleConfig(WebRequestWrapper* webRequestWrapper) webRequestWrapper->sendContent(content); #ifdef IOTWEBCONF_DEBUG_TO_SERIAL - Serial.println("Rendering parameters:"); - this->_systemParameters.debugTo(&Serial); - this->_customParameterGroups.debugTo(&Serial); + //Serial.println("Rendering parameters:"); + //this->_systemParameters.debugTo(&Serial); + //this->_customParameterGroups.debugTo(&Serial); #endif // -- Add parameters to the form this->_systemParameters.renderHtml(dataArrived, webRequestWrapper); @@ -311,19 +350,8 @@ void IotWebConf::handleConfig(WebRequestWrapper* webRequestWrapper) content = htmlFormatProvider->getFormEnd(); - if (this->_updatePath != nullptr) - { - String pitem = htmlFormatProvider->getUpdate(); - pitem.replace("{u}", this->_updatePath); - content += pitem; - } - - // -- Fill config version string; - { - String pitem = htmlFormatProvider->getConfigVer(); - pitem.replace("{v}", this->_configVersion); - content += pitem; - } + content += this->getUpdateLinkHtml(); + content += this->getConfigVersionHtml(); content += htmlFormatProvider->getEnd(); @@ -355,20 +383,44 @@ void IotWebConf::handleConfig(WebRequestWrapper* webRequestWrapper) page += "Configuration saved. "; if (this->_apPassword[0] == '\0') { - page += F("You must change the default AP password to continue. Return " - "to configuration page."); - } - else if (this->_wifiParameters._wifiSsid[0] == '\0') - { - page += F("You must provide the local wifi settings to continue. Return " - "to configuration page."); + if (this->_configAPPasswordMissingPage != nullptr) + { + this->_configAPPasswordMissingPage(webRequestWrapper); + return; + } + else + { + page += F("You must change the default AP password to continue. Return " + "to configuration page."); + } } - else if (this->_state == NotConfigured) - { - page += F("Please disconnect from WiFi AP to continue!"); + else if (this->_wifiParameters._wifiSsid[0] == '\0') { + if (this->_configSSIDNotConfiguredPage != nullptr) + { + this->_configSSIDNotConfiguredPage(webRequestWrapper); + return; + } + else + { + page += F("You must provide the local wifi settings to continue. " + "Return to configuration page."); + } } - else + else if (this->_state == NotConfigured) { + if (this->_configNotConfiguredPage != nullptr) + { + this->_configNotConfiguredPage(webRequestWrapper); + return; + } else { + page += F("Please disconnect from WiFi AP to continue!"); + } + } + else { + if (this->_configSavedPage != nullptr){ + this->_configSavedPage(webRequestWrapper); + return; + } page += F("Return to home page."); } page += htmlFormatProvider->getEnd(); @@ -460,12 +512,12 @@ bool IotWebConf::handleCaptivePortal(WebRequestWrapper* webRequestWrapper) if (!isIp(host) && !host.startsWith(thingName)) { #ifdef IOTWEBCONF_DEBUG_TO_SERIAL - Serial.print("Request for "); - Serial.print(host); - Serial.print(" redirected to "); - Serial.print(webRequestWrapper->localIP()); - Serial.print(":"); - Serial.println(webRequestWrapper->localPort()); + //Serial.print("Request for "); + //Serial.print(host); + //Serial.print(" redirected to "); + //Serial.print(webRequestWrapper->localIP()); + //Serial.print(":"); + //Serial.println(webRequestWrapper->localPort()); #endif webRequestWrapper->sendHeader( "Location", String("http://") + toStringIp(webRequestWrapper->localIP()) + ":" + webRequestWrapper->localPort(), true); diff --git a/src/IotWebConf.h b/src/IotWebConf.h index 3bdb460..27f5a62 100644 --- a/src/IotWebConf.h +++ b/src/IotWebConf.h @@ -293,6 +293,24 @@ class IotWebConf */ void setConfigSavedCallback(std::function func); + void setConfigAPPasswordMissingPage( + std::function func); + + void setConfigSSIDNotConfiguredPage( + std::function func); + + void setConfigNotConfiguredPage( + std::function func); + + /** + * Sets a custom function that will be called when the configuration page is + * saved. This function can be used to perform custom actions after the + * configuration has been successfully saved. + * @func - A function that accepts a WebRequestWrapper as a parameter + */ + void setConfigSavedPage(std::function func); + + /** * Specify a callback method, that will be called when form validation is required. * If the method will return false, the configuration will not be saved. @@ -505,6 +523,11 @@ class IotWebConf { return &this->_systemParameters; }; + + ParameterGroup* getCustomParameterGroup() { + return &this->_customParameterGroups; + } + Parameter* getThingNameParameter() { return &this->_thingNameParameter; @@ -530,6 +553,14 @@ class IotWebConf return &this->_apTimeoutParameter; }; + const char* getApPassword() const { + return this->_apPassword; + } + + String getUpdateLinkHtml(); + String getConfigVersionHtml(); + + /** * If config parameters are modified directly, the new values can be saved by this method. * Note, that init() must pretend saveConfig()! @@ -558,6 +589,8 @@ class IotWebConf return this->htmlFormatProvider; } + bool validateForm(WebRequestWrapper* webRequestWrapper); + private: const char* _initialApPassword = nullptr; const char* _configVersion; @@ -600,6 +633,10 @@ class IotWebConf std::function _wifiConnectionCallback = nullptr; std::function _configSavingCallback = nullptr; std::function _configSavedCallback = nullptr; + std::function _configSSIDNotConfiguredPage = nullptr; + std::function _configAPPasswordMissingPage = nullptr; + std::function _configNotConfiguredPage = nullptr; + std::function _configSavedPage = nullptr; std::function _formValidator = nullptr; std::function _apConnectionHandler = &(IotWebConf::connectAp); @@ -626,7 +663,7 @@ class IotWebConf void readEepromValue(int start, byte* valueBuffer, int length); void writeEepromValue(int start, byte* valueBuffer, int length); - bool validateForm(WebRequestWrapper* webRequestWrapper); + void changeState(NetworkState newState); void stateChanged(NetworkState oldState, NetworkState newState); diff --git a/src/IotWebConfESP32HTTPUpdateServer.h b/src/IotWebConfESP32HTTPUpdateServer.h index b1357bf..cabcfe2 100644 --- a/src/IotWebConfESP32HTTPUpdateServer.h +++ b/src/IotWebConfESP32HTTPUpdateServer.h @@ -152,14 +152,45 @@ class HTTPUpdateServer String _password; bool _authenticated; String _updaterError; - const char* serverIndex PROGMEM = -R"(
- - -
- )"; - const char* successResponse PROGMEM = -"Update Success! Rebooting...\n"; + const char* serverIndex PROGMEM =R"( + + + + + +
+ + + +
+
+
+ Firmware update +
+ + +
+
+
+ + + +
Go to configure page to change configuration.
Go to main page.
+ + )"; + const char* successResponse PROGMEM = + "Update Success! " + "Rebooting...\n"; }; ///////////////////////////////////////////////////////////////////////////////// diff --git a/src/IotWebConfOptionalGroup.cpp b/src/IotWebConfOptionalGroup.cpp index 9e64649..6f52986 100644 --- a/src/IotWebConfOptionalGroup.cpp +++ b/src/IotWebConfOptionalGroup.cpp @@ -61,8 +61,7 @@ void OptionalParameterGroup::loadValue( ParameterGroup::loadValue(doLoad); } -void OptionalParameterGroup::renderHtml( - bool dataArrived, WebRequestWrapper* webRequestWrapper) +void OptionalParameterGroup::renderHtml(bool dataArrived, WebRequestWrapper* webRequestWrapper) { if (this->label != nullptr) { @@ -100,6 +99,73 @@ void OptionalParameterGroup::renderHtml( } } +bool OptionalParameterGroup::renderHtml( + bool dataArrived, WebRequestWrapper* webRequestWrapper, + HtmlChunkCallback outputCallback) +{ + ConfigItem* current_ = _currentItem ? _currentItem : this->_firstItem; + + if (_currentItem == nullptr) + { + _startTemplateSend = false; + } + + if (!_startTemplateSend && this->label != nullptr) + { + String content_ = getStartTemplate(); + content_.replace("{b}", this->label); + content_.replace("{i}", this->getId()); + content_.replace("{v}", this->_active ? "active" : "inactive"); + if (this->_active) + { + content_.replace("{cb}", "hide"); + content_.replace("{cf}", ""); + } + else + { + content_.replace("{cb}", ""); + content_.replace("{cf}", "hide"); + } + size_t written = outputCallback(content_.c_str(), content_.length()); + if (written < content_.length()) + { + return false; + } + _startTemplateSend = true; + } + + while (current_ != nullptr) + { + if (current_->visible) + { + bool completed_ = + current_->renderHtml(dataArrived, webRequestWrapper, outputCallback); + if (!completed_) + { + _currentItem = current_; + return false; + } + } + current_ = this->getNextItemOf(current_); + } + + if (this->label != nullptr) + { + String content_ = getEndTemplate(); + content_.replace("{b}", this->label); + content_.replace("{i}", this->getId()); + size_t written = outputCallback(content_.c_str(), content_.length()); + if (written < content_.length()) + { + return false; + } + } + + _currentItem = nullptr; + _startTemplateSend = false; + return true; +} + void OptionalParameterGroup::update(WebRequestWrapper* webRequestWrapper) { // -- Get active variable diff --git a/src/IotWebConfOptionalGroup.h b/src/IotWebConfOptionalGroup.h index 60a2b1f..74f6a28 100644 --- a/src/IotWebConfOptionalGroup.h +++ b/src/IotWebConfOptionalGroup.h @@ -71,8 +71,8 @@ class OptionalParameterGroup : public ParameterGroup { public: OptionalParameterGroup(const char* id, const char* label, bool defaultActive); - bool isActive() { return this->_active; } - void setActive(bool active) { this->_active = active; } + virtual bool isActive() const { return this->_active; } + virtual void setActive(bool active) { this->_active = active; } protected: int getStorageSize() override; @@ -82,6 +82,7 @@ class OptionalParameterGroup : public ParameterGroup void loadValue(std::function doLoad) override; void renderHtml(bool dataArrived, WebRequestWrapper* webRequestWrapper) override; + bool renderHtml(bool dataArrived, WebRequestWrapper* webRequestWrapper, HtmlChunkCallback outputCallback) override; virtual String getStartTemplate() { return FPSTR(IOTWEBCONF_HTML_FORM_OPTIONAL_GROUP_START); }; virtual String getEndTemplate() { return FPSTR(IOTWEBCONF_HTML_FORM_OPTIONAL_GROUP_END); }; void update(WebRequestWrapper* webRequestWrapper) override; @@ -90,6 +91,8 @@ class OptionalParameterGroup : public ParameterGroup private: bool _defaultActive; bool _active; + bool _continueRendering = false; + }; class ChainedParameterGroup; diff --git a/src/IotWebConfParameter.cpp b/src/IotWebConfParameter.cpp index 83aa153..f0f2a0f 100644 --- a/src/IotWebConfParameter.cpp +++ b/src/IotWebConfParameter.cpp @@ -110,6 +110,64 @@ void ParameterGroup::renderHtml( webRequestWrapper->sendContent(content); } } + +bool ParameterGroup::renderHtml( + bool dataArrived, WebRequestWrapper* webRequestWrapper, + HtmlChunkCallback outputCallback) +{ + ConfigItem* current_ = _currentItem ? _currentItem : this->_firstItem; + + if (_currentItem == nullptr) + { + _startTemplateSend = false; + } + + if (!_startTemplateSend && this->label != nullptr) + { + String content_ = getStartTemplate(); + content_.replace("{b}", this->label); + content_.replace("{i}", this->getId()); + size_t written = outputCallback(content_.c_str(), content_.length()); + if (written < content_.length()) + { + // Not everything was written + return false; + } + _startTemplateSend = true; + } + + while (current_ != nullptr) + { + if (current_->visible) + { + bool completed_ = + current_->renderHtml(dataArrived, webRequestWrapper, outputCallback); + if (!completed_) + { + _currentItem = current_; + return false; + } + } + current_ = this->getNextItemOf(current_); + } + + if (this->label != nullptr) + { + String content_ = getEndTemplate(); + content_.replace("{b}", this->label); + content_.replace("{i}", this->getId()); + size_t written = outputCallback(content_.c_str(), content_.length()); + if (written < content_.length()) + { + return false; + } + } + + _currentItem = nullptr; + _startTemplateSend = false; + return true; +} + void ParameterGroup::update(WebRequestWrapper* webRequestWrapper) { ConfigItem* current = this->_firstItem; @@ -295,8 +353,7 @@ TextParameter::TextParameter( this->customHtml = customHtml; } -void TextParameter::renderHtml( - bool dataArrived, WebRequestWrapper* webRequestWrapper) +void TextParameter::renderHtml(bool dataArrived, WebRequestWrapper* webRequestWrapper) { String content = this->renderHtml( dataArrived, @@ -304,6 +361,32 @@ void TextParameter::renderHtml( webRequestWrapper->arg(this->getId())); webRequestWrapper->sendContent(content); } + +bool TextParameter::renderHtml(bool dataArrived, WebRequestWrapper* webRequestWrapper, HtmlChunkCallback outputCallback){ + String content = this->renderHtml(dataArrived, webRequestWrapper->hasArg(this->getId()), webRequestWrapper->arg(this->getId())); + size_t contentLen = content.length(); + + while (_lastSentPos < contentLen){ + size_t remaining = contentLen - _lastSentPos; + + // Try to write the remaining data + size_t written = outputCallback(content.c_str() + _lastSentPos, remaining); + + if (written == 0){ + // Callback could not write anything - buffer is full + // Don't update position, will retry on next call + return false; + } + + // Update position with what was actually written + _lastSentPos += written; + } + + // All data sent + _lastSentPos = 0; // Reset for next parameter + return true; +} + String TextParameter::renderHtml( bool dataArrived, bool hasValueFromPost, String valueFromPost) { @@ -337,7 +420,7 @@ String TextParameter::renderHtml( "{c}", current->customHtml == nullptr ? "" : current->customHtml); pitem.replace( "{s}", - current->errorMessage == nullptr ? "" : "de"); // Div style class. + current->errorMessage == nullptr ? current->getId() : "de"); // Div style class. pitem.replace( "{e}", current->errorMessage == nullptr ? "" : current->errorMessage); @@ -621,4 +704,207 @@ size_t PrefixStreamWrapper::checkNewLine() return 0; } +/////////////////////////////////////////////////////////////////////////////// + +// Static day names initialization (English by default) +// Array index matches day of week: 0=Sunday, 1=Monday, ..., 6=Saturday +const char* DayParameter::_dayNamesShort[7] = {"Sun", "Mon", "Tue", "Wed", + "Thu", "Fri", "Sat"}; + +bool DayParameter::isDaySelected(int dayOfWeek) const +{ + if (dayOfWeek < 0 || dayOfWeek > 6) + return false; + + String value = String(valueBuffer); + String dayStr = String(dayOfWeek); + + // Check if day is in comma-separated list + int startPos = 0; + while (startPos < value.length()) + { + int commaPos = value.indexOf(',', startPos); + if (commaPos == -1) + commaPos = value.length(); + + String token = value.substring(startPos, commaPos); + token.trim(); + if (token == dayStr) + return true; + + startPos = commaPos + 1; + } + return false; +} + +int DayParameter::getSelectedDays(int* selectedDays) const +{ + int count = 0; + for (int day = 0; day <= 6; day++) + { + if (isDaySelected(day)) + { + selectedDays[count++] = day; + } + } + return count; +} + +void DayParameter::setSelectedDays(const int* selectedDays, int count) +{ + this->valueBuffer[0] = '\0'; + + for (int i = 0; i < count && i < 7; i++) + { + if (i > 0) + strcat(this->valueBuffer, ","); + char dayStr[4]; + itoa(selectedDays[i], dayStr, 10); + strncat( + this->valueBuffer, dayStr, + this->getLength() - strlen(this->valueBuffer) - 1); + } + this->valueBuffer[this->getLength()] = '\0'; +} + +String DayParameter::renderHtml( + bool dataArrived, bool hasValueFromPost, String valueFromPost) +{ + String currentValue = + hasValueFromPost ? valueFromPost : String(this->valueBuffer); + + // Parse currently selected days + bool selectedDays[7] = {false}; + int startPos = 0; + while (startPos < currentValue.length()) + { + int commaPos = currentValue.indexOf(',', startPos); + if (commaPos == -1) + commaPos = currentValue.length(); + + String token = currentValue.substring(startPos, commaPos); + token.trim(); + int dayNum = token.toInt(); + if (dayNum >= 0 && dayNum <= 6) + { + selectedDays[dayNum] = true; + } + + startPos = commaPos + 1; + } + + // Build HTML + String html = "
"; + + const char* inputType = _multiSelect ? "checkbox" : "radio"; + + for (int i = 0; i < 7; i++) + { + int dayNumber = i; + String dayParamId = getDayParamId(dayNumber); + + html += ""; + } + + html += "
"; + + // Hidden field to store the combined value + html += ""; + + // JavaScript to update hidden field on change + html += ""; + + if (this->errorMessage != nullptr) + { + html += "
"; + html += this->errorMessage; + html += "
"; + } + + html += "
"; + + return html; +} + +void DayParameter::update(WebRequestWrapper* webRequestWrapper) +{ + if (!this->visible) + { + return; + } + + // The hidden field contains the comma-separated values + if (webRequestWrapper->hasArg(this->getId())) + { + String newValue = webRequestWrapper->arg(this->getId()); + strncpy(this->valueBuffer, newValue.c_str(), this->getLength()); + this->valueBuffer[this->getLength()] = '\0'; + } + else + { + // No value submitted (shouldn't happen with hidden field) + this->valueBuffer[0] = '\0'; + } +} + +void DayParameter::update(String newValue) +{ + strncpy(this->valueBuffer, newValue.c_str(), this->getLength()); + this->valueBuffer[this->getLength()] = '\0'; +} + +/////////////////////////////////////////////////////////////////////////////// + } // end namespace \ No newline at end of file diff --git a/src/IotWebConfParameter.h b/src/IotWebConfParameter.h index 1f83691..9fd1dea 100644 --- a/src/IotWebConfParameter.h +++ b/src/IotWebConfParameter.h @@ -38,6 +38,8 @@ const char IOTWEBCONF_HTML_FORM_SELECT_PARAM[] PROGMEM = const char IOTWEBCONF_HTML_FORM_OPTION[] PROGMEM = "\n"; +typedef std::function HtmlChunkCallback; + namespace iotwebconf { @@ -51,7 +53,7 @@ class ConfigItem { public: bool visible = true; - const char* getId() { return this->_id; } + const char* getId() const { return this->_id; } /** * Calculate the size of bytes should be stored in the EEPROM. @@ -90,7 +92,7 @@ class ConfigItem * The webRequestWrapper->sendContent() method should be used in the implementations. */ virtual void renderHtml(bool dataArrived, WebRequestWrapper* webRequestWrapper) = 0; - + virtual bool renderHtml(bool dataArrived, WebRequestWrapper* webRequestWrapper, HtmlChunkCallback outputCallback) = 0; /** * New value arrived from the form post. The value should be stored in the * in this config item. @@ -141,13 +143,16 @@ class ParameterGroup : public ConfigItem virtual void loadFromJson(JsonObject jsonObject) override; #endif + void renderHtml(bool dataArrived, WebRequestWrapper* webRequestWrapper) override; + bool renderHtml(bool dataArrived, WebRequestWrapper* webRequestWrapper, HtmlChunkCallback outputCallback) override; + protected: int getStorageSize() override; void storeValue(std::function doStore) override; void loadValue(std::function doLoad) override; - void renderHtml(bool dataArrived, WebRequestWrapper* webRequestWrapper) override; + void update(WebRequestWrapper* webRequestWrapper) override; void clearErrorMessage() override; void debugTo(Stream* out) override; @@ -163,6 +168,10 @@ class ParameterGroup : public ConfigItem virtual String getEndTemplate() { return FPSTR(IOTWEBCONF_HTML_FORM_GROUP_END); }; ConfigItem* _firstItem = nullptr; + ConfigItem* _currentItem = nullptr; + bool _startTemplateSend = false; + bool _continueRendering = false; + size_t _currentStringPos = 0; ConfigItem* getNextItemOf(ConfigItem* parent) { return parent->_nextItem; }; friend class IotWebConf; // Allow IotWebConf to access protected members. @@ -253,10 +262,10 @@ class TextParameter : public Parameter const char* customHtml; protected: - virtual String renderHtml( - bool dataArrived, bool hasValueFromPost, String valueFromPost); + virtual String renderHtml(bool dataArrived, bool hasValueFromPost, String valueFromPost); // Overrides virtual void renderHtml(bool dataArrived, WebRequestWrapper* webRequestWrapper) override; + virtual bool renderHtml(bool dataArrived, WebRequestWrapper* webRequestWrapper, HtmlChunkCallback outputCallback) override; virtual void update(String newValue) override; virtual void debugTo(Stream* out) override; /** @@ -271,6 +280,8 @@ class TextParameter : public Parameter */ virtual String renderHtml(const char* type, bool hasValueFromPost, String valueFromPost); + size_t _lastSentPos = 0; + private: friend class IotWebConf; friend class WifiParameterGroup; @@ -334,6 +345,13 @@ class NumberParameter : public TextParameter virtual String renderHtml( bool dataArrived, bool hasValueFromPost, String valueFromPost) override; + virtual String getHtmlTemplate() override { + return F("
" + "
{e}
\n"); + } + private: friend class IotWebConf; }; @@ -432,6 +450,150 @@ class SelectParameter : public OptionsParameter friend class IotWebConf; }; +/////////////////////////////////////////////////////////////////////////////// + +/** + * Date parameter is an option parameter, that rendered as HTML Date picker. + */ +class DateParameter : public TextParameter { +public: + /** + * (See TextParameter for arguments!) + */ + DateParameter( + const char* label, const char* id, char* valueBuffer, int length, + const char* defaultValue = nullptr) + : iotwebconf::TextParameter( + label, id, valueBuffer, length, defaultValue, nullptr, nullptr) { + } + +protected: + virtual String renderHtml( + bool dataArrived, bool hasValueFromPost, String valueFromPost) override + { + return TextParameter::renderHtml("date", hasValueFromPost, valueFromPost); + }; + +private: + friend class IotWebConf; +}; + +/////////////////////////////////////////////////////////////////////////////// + +/** + * Time parameter is an option parameter, that rendered as HTML time picker. + */ +class TimeParameter : public TextParameter { +public: + /** + * (See TextParameter for arguments!) + */ + TimeParameter( + const char* label, const char* id, char* valueBuffer, int length, + const char* defaultValue = nullptr) + : iotwebconf::TextParameter( + label, id, valueBuffer, length, defaultValue, nullptr, nullptr) { + } + +protected: + virtual String renderHtml(bool dataArrived, bool hasValueFromPost, String valueFromPost) override + { + return TextParameter::renderHtml("time", hasValueFromPost, valueFromPost); + }; + +private: + friend class IotWebConf; +}; + +/////////////////////////////////////////////////////////////////////////////// + +/** + * Day parameter renders checkboxes or radio buttons for weekday selection. + * The value is stored as a comma-separated string (e.g., "1,3,5" for + * Mon,Wed,Fri). + */ +class DayParameter : public TextParameter +{ +public: + /** + * Create a day parameter for the config portal. + * + * @label - Displayable label at the config portal. + * @id - Identifier used for HTTP queries and as configuration key. + * @valueBuffer - Configuration value will be loaded to this buffer. + * @length - The buffer length (recommended: 32). + * @multiSelect - If true, renders checkboxes (multi-select). If false, + * renders radio buttons (single-select). + * @defaultValue - Default value (e.g., "1,2,3,4,5" for Mon-Fri). + */ + DayParameter( + const char* label, const char* id, char* valueBuffer, int length, + bool multiSelect = true, const char* defaultValue = nullptr) + : iotwebconf::TextParameter( + label, id, valueBuffer, length, defaultValue, nullptr, nullptr), + _multiSelect(multiSelect) + { + } + + /** + * Set custom day names (short names used in rendering) - applies to ALL + * instances. + * @dayNamesShort - Array of 7 short strings (Mon to Sun). + * Example: const char* germanDaysShort[7] = {"Mo", "Di", "Mi", "Do", "Fr", + * "Sa", "So"}; DayParameter::setDayNamesShort(germanDaysShort); + */ + static void setDayNamesShort(const char* dayNamesShort[7]) + { + for (int i = 0; i < 7; i++) + { + _dayNamesShort[i] = dayNamesShort[i]; + } + } + + /** + * Check if a specific day is selected. + * @dayOfWeek - Day of week (0=Sunday, 1=Monday, ..., 6=Saturday) + * @return true if the day is selected + */ + bool isDaySelected(int dayOfWeek) const; + + /** + * Get all selected days as an array. + * @selectedDays - Output array (must be at least size 7) + * @return Number of selected days (day values: 0=Sunday, 1=Monday, ..., + * 6=Saturday) + */ + int getSelectedDays(int* selectedDays) const; + + /** + * Set which days are selected. + * @selectedDays - Array of day numbers (0=Sunday, 1=Monday, ..., 6=Saturday) + * @count - Number of days in array + */ + void setSelectedDays(const int* selectedDays, int count); + +protected: + virtual String renderHtml( + bool dataArrived, bool hasValueFromPost, String valueFromPost) override; + virtual void update(WebRequestWrapper* webRequestWrapper) override; + virtual void update(String newValue) override; + +private: + friend class IotWebConf; + bool _multiSelect; + + // Helper to get child parameter ID for a specific day + String getDayParamId(int dayNumber) const + { + return String(this->getId()) + "_" + String(dayNumber); + } + + // Day names (static - shared by all instances) + static const char* _dayNamesShort[7]; +}; + +/////////////////////////////////////////////////////////////////////////////// + /** * This class is here just to make some nice indents on debug output * for group tree. diff --git a/src/IotWebConfTParameter.h b/src/IotWebConfTParameter.h index aab28d2..077a28b 100644 --- a/src/IotWebConfTParameter.h +++ b/src/IotWebConfTParameter.h @@ -385,8 +385,7 @@ class InputParameter : virtual public ConfigItemBridge ConfigItemBridge::ConfigItemBridge(id), label(label) { } - virtual void renderHtml( - bool dataArrived, WebRequestWrapper* webRequestWrapper) override + virtual void renderHtml(bool dataArrived, WebRequestWrapper* webRequestWrapper) override { String content = this->renderHtml( dataArrived, @@ -395,6 +394,14 @@ class InputParameter : virtual public ConfigItemBridge webRequestWrapper->sendContent(content); } + virtual bool renderHtml(bool dataArrived, WebRequestWrapper* webRequestWrapper, HtmlChunkCallback outputCallback) override { + String content = this->renderHtml( + dataArrived, + webRequestWrapper->hasArg(this->getId()), + webRequestWrapper->arg(this->getId())); + return outputCallback(content.c_str(), content.length()); + } + const char* label; /** diff --git a/src/IotWebConfTab.h b/src/IotWebConfTab.h new file mode 100644 index 0000000..2b16674 --- /dev/null +++ b/src/IotWebConfTab.h @@ -0,0 +1,460 @@ +/** + * IotWebConfTab.h -- Extension of IotWebConf that adds tab support + * to the configuration page. + * https://github.com/minou65/IotWebConf + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +#ifndef IotWebConfTab_h +#define IotWebConfTab_h + +#include +#include +#include + +namespace iotwebconf +{ + +/** + * Structure to hold tab information + */ +struct TabInfo +{ + const char* tabName; + ParameterGroup* group; +}; + +/** + * Custom HTML format provider that adds tab support + */ +class TabHtmlFormatProvider : public HtmlFormatProvider +{ +public: + TabHtmlFormatProvider( + std::vector* tabs, int minWidth = 500, int maxWidth = 600) + : _tabs(tabs), _minWidth(minWidth), _maxWidth(maxWidth) + { + } + + /** + * Set the container width constraints + */ + void setContainerWidth(int minWidth, int maxWidth) + { + _minWidth = minWidth; + _maxWidth = maxWidth; + } + + String getStyleInner() override + { + String style = HtmlFormatProvider::getStyleInner(); + + // Main container - configurable width for consistency across tabs + style += "body > div{min-width:" + String(_minWidth) + + "px;max-width:" + String(_maxWidth) + + "px;width:100%;box-sizing:border-box;}\n"; + + // Tab container styling + style += + ".tab{overflow:hidden;border-bottom:2px solid " + "#16A1E7;background-color:#f1f1f1;margin-bottom:10px;display:flex;}\n"; + // Tab button styling + style += ".tab button{background-color:#f1f1f1 !important;flex:1 1 " + "0;min-width:0;border:1px solid #ccc " + "!important;outline:none;cursor:pointer;"; + style += "padding:14px " + "16px;transition:0.3s;font-size:16px;border-top-left-radius:5px;" + "border-top-right-radius:5px;"; + style += "margin-right:2px;border-bottom:none !important;color:#333 " + "!important;line-height:normal !important;width:auto " + "!important;box-sizing:border-box;}\n"; + // Tab button hover + style += ".tab button:hover{background-color:#ddd !important;}\n"; + // Active tab button + style += + ".tab button.active{background-color:#16A1E7 !important;color:white " + "!important;border:1px solid #16A1E7 !important;border-bottom:2px " + "solid #fff !important;position:relative;z-index:1;}\n"; + // Tab content - ALL hidden by default + style += ".tabcontent{display:none;padding:12px;border:1px solid " + "#ccc;border-top:none;background-color:#fff;}\n"; + // Fieldsets consistent width + style += "fieldset{width:100%;box-sizing:border-box;}\n"; + + return style; + } + + String getScriptInner() override + { + String script = HtmlFormatProvider::getScriptInner(); + script += "function openTab(evt,tabName){var i,tabcontent,tablinks;"; + script += "tabcontent=document.getElementsByClassName('tabcontent');"; + script += + "for(i=0;i* _tabs; + int _minWidth; + int _maxWidth; +}; + +/** + * Extended IotWebConf class with tab support + */ +class IotWebConfTab : public IotWebConf +{ +public: + /** + * Create a new configuration handler with tab support. + */ + IotWebConfTab( + const char* thingName, DNSServer* dnsServer, WebServer* server, + const char* initialApPassword, const char* configVersion = "init") + : IotWebConf( + thingName, dnsServer, server, initialApPassword, configVersion), + _systemTabPosition(0) + { + _tabHtmlFormatProvider = new TabHtmlFormatProvider(&_tabs); + setHtmlFormatProvider(_tabHtmlFormatProvider); + + // System-Parameter immer zum ersten Tab hinzufügen + _systemTabName = "System"; + } + + IotWebConfTab( + const char* thingName, DNSServer* dnsServer, WebServerWrapper* server, + const char* initialApPassword, const char* configVersion = "init") + : IotWebConf( + thingName, dnsServer, server, initialApPassword, configVersion), + _systemTabPosition(0) + { + _tabHtmlFormatProvider = new TabHtmlFormatProvider(&_tabs); + setHtmlFormatProvider(_tabHtmlFormatProvider); + + _systemTabName = "System"; + } + + ~IotWebConfTab() { delete _tabHtmlFormatProvider; } + + /** + * Set the tab name for system parameters (ThingName, WiFi, AP Password). + * Must be called before init()! + */ + void setSystemTabName(const char* tabName) { _systemTabName = tabName; } + + /** + * Set the position of the system tab + * @param position Position index: + * 0 = first position (default) + * 1..n = specific position (1-based) + * -1 = last position + */ + void setSystemTabPosition(int position) { _systemTabPosition = position; } + + /** + * Set the container width constraints + */ + void setContainerWidth(int minWidth, int maxWidth) + { + if (_tabHtmlFormatProvider != nullptr) + { + _tabHtmlFormatProvider->setContainerWidth(minWidth, maxWidth); + } + } + + /** + * Add a parameter group with tab assignment. + */ + void addParameterGroup(ParameterGroup* group, const char* tabName) + { + IotWebConf::addParameterGroup(group); + + TabInfo tabInfo; + tabInfo.tabName = tabName; + tabInfo.group = group; + _tabs.push_back(tabInfo); + + // Don't add system tab name to unique tabs list + if (strcmp(tabName, _systemTabName) == 0) + { + return; + } + + if (_uniqueTabNames.find(tabName) == _uniqueTabNames.end()) + { + _uniqueTabNames[tabName] = _uniqueTabsList.size(); + _uniqueTabsList.push_back(tabName); + } + } + + /** + * Override for compatibility - adds to default "Configuration" tab + */ + void addParameterGroup(ParameterGroup* group) + { + addParameterGroup(group, "Configuration"); + } + + /** + * Config URL web request handler with tab support. + */ + void handleConfig(WebRequestWrapper* webRequestWrapper) + { + if (webRequestWrapper->hasArg("iotSave")) + { + IotWebConf::handleConfig(webRequestWrapper); + return; + } + + if (this->getState() == OnLine) + { + if (!webRequestWrapper->authenticate( + IOTWEBCONF_ADMIN_USER_NAME, getApPassword())) + { + webRequestWrapper->requestAuthentication(); + return; + } + } + + HtmlFormatProvider* hfp = getHtmlFormatProvider(); + String page = hfp->getHead(); + page.replace("{v}", String("Config ") + this->getThingName()); + page += hfp->getStyle(); + page += hfp->getScript(); + page += hfp->getHeadExtension(); + page += hfp->getHeadEnd(); + + // Generate tab buttons with system tab positioning + page += generateTabButtons(); + + page += hfp->getFormStart(); + + // Render tabs content with system tab at correct position + renderTabsContent(page); + + page += hfp->getFormEnd(); + page += getUpdateLinkHtml(); + page += getConfigVersionHtml(); + page += hfp->getEnd(); + + webRequestWrapper->sendHeader( + "Cache-Control", "no-cache, no-store, must-revalidate"); + webRequestWrapper->sendHeader("Pragma", "no-cache"); + webRequestWrapper->sendHeader("Expires", "-1"); + webRequestWrapper->send(200, "text/html; charset=UTF-8", page); + } + + void handleConfig() + { + StandardWebRequestWrapper webRequestWrapper = + StandardWebRequestWrapper(this->_standardWebServerWrapper._server); + handleConfig(&webRequestWrapper); + } + +private: + std::vector _tabs; + std::map _uniqueTabNames; + std::vector _uniqueTabsList; + TabHtmlFormatProvider* _tabHtmlFormatProvider = nullptr; + const char* _systemTabName; + int _systemTabPosition; + + String generateTabButtons() + { + String buttons = "
\n"; + + // Calculate system tab position + int systemPos = _systemTabPosition; + size_t totalCustomTabs = _uniqueTabsList.size(); + + // Clamp position to valid range + if (systemPos < 0) + { + systemPos = totalCustomTabs; // End + } + if (systemPos > (int)totalCustomTabs) + { + systemPos = totalCustomTabs; + } + + int customTabsAdded = 0; + bool systemTabAdded = false; + + // Generate all tabs in order + for (int pos = 0; pos <= (int)totalCustomTabs; pos++) + { + // Check if system tab should be at this position + if (pos == systemPos && !systemTabAdded) + { + buttons += "\n"; + systemTabAdded = true; + } + + // Add custom tab if available and we haven't added all yet + if (customTabsAdded < (int)totalCustomTabs) + { + buttons += "\n"; + customTabsAdded++; + } + } + + buttons += "
\n"; + return buttons; + } + + void renderTabsContent(String& page) + { + // Calculate system tab position + int systemPos = _systemTabPosition; + size_t totalCustomTabs = _uniqueTabsList.size(); + + // Clamp position to valid range + if (systemPos < 0) + { + systemPos = totalCustomTabs; + } + if (systemPos > (int)totalCustomTabs) + { + systemPos = totalCustomTabs; + } + + int customTabsAdded = 0; + bool systemTabAdded = false; + + // Render all tabs in order + for (int pos = 0; pos <= (int)totalCustomTabs; pos++) + { + // Check if system tab should be at this position + if (pos == systemPos && !systemTabAdded) + { + // System tab + page += "
renderHtml(false, &appender); + } + + friend TabHtmlFormatProvider; +}; + +} // namespace iotwebconf + +using iotwebconf::IotWebConfTab; + +#endif \ No newline at end of file