/** * AutoConnect class implementation. * @file AutoConnect.cpp * @author hieromon@gmail.com * @version 0.9.7 * @date 2018-11-17 * @copyright MIT license. */ #include "AutoConnect.h" #ifdef ARDUINO_ARCH_ESP32 #include #endif /** * An actual reset function dependent on the architecture */ #if defined(ARDUINO_ARCH_ESP8266) #define SOFT_RESET() ESP.reset() #elif defined(ARDUINO_ARCH_ESP32) #define SOFT_RESET() ESP.restart() #endif /** * AutoConnect default constructor. This entry activates WebServer * internally and the web server is allocated internal. */ AutoConnect::AutoConnect() { _initialize(); _webServer.reset(nullptr); _dnsServer.reset(nullptr); _webServerAlloc = AC_WEBSERVER_HOSTED; } /** * Run the AutoConnect site using the externally ensured ESP 8266 WebServer. * User's added URI handler response can be included in handleClient method. * @param webServer A reference of ESP8266WebServer instance. */ AutoConnect::AutoConnect(WebServerClass& webServer) { _initialize(); _webServer.reset(&webServer); _dnsServer.reset(nullptr); _webServerAlloc = AC_WEBSERVER_PARASITIC; } void AutoConnect::_initialize() { _rfConnect = false; _rfReset = false; _responsePage = nullptr; _currentPageElement = nullptr; _menuTitle = String(AUTOCONNECT_MENU_TITLE); _portalTimeout = AUTOCONNECT_TIMEOUT; memset(&_credential, 0x00, sizeof(struct station_config)); _aux.release(); } /** * A destructor. Free AutoConnect web pages and release Web server. * When the server is hosted it will be purged. */ AutoConnect::~AutoConnect() { end(); } /** * Starts establishing WiFi connection without SSID and password. */ bool AutoConnect::begin() { return begin(nullptr, nullptr); } /** * Starts establishing WiFi connection. * Before establishing, start the Web server and DNS server for the captive * portal. Then begins connection establishment in WIFI_STA mode. If * connection can not established with the specified SSID and password, * switch to WIFI_AP_STA mode and activate SoftAP. * @param ssid SSID to be connected. * @param passphrase Password for connection. * @param timeout A time out value in milliseconds for waiting connection. * @retval true Connection established, AutoConnect service started with WIFI_STA mode. * @retval false Could not connected, Captive portal started with WIFI_AP_STA mode. */ bool AutoConnect::begin(const char* ssid, const char* passphrase, unsigned long timeout) { bool cs; // Overwrite for the current timeout value. _portalTimeout = timeout; // Start WiFi connection with station mode. // WiFi.softAPdisconnect(true); WiFi.enableAP(false); WiFi.mode(WIFI_STA); delay(100); // Advance configuration for STA mode. #ifdef AC_DEBUG String staip_s = _apConfig.staip.toString(); String staGateway_s = _apConfig.staGateway.toString(); String staNetmask_s = _apConfig.staNetmask.toString(); String dns1_s = _apConfig.dns1.toString(); String dns2_s = _apConfig.dns2.toString(); AC_DBG("WiFi.config(IP=%s, Gateway=%s, Subnetmask=%s, DNS1=%s, DNS2=%s)\n", staip_s.c_str(), staGateway_s.c_str(), staNetmask_s.c_str(), dns1_s.c_str(), dns2_s.c_str()); #endif if (!WiFi.config(_apConfig.staip, _apConfig.staGateway, _apConfig.staNetmask, _apConfig.dns1, _apConfig.dns2)) { AC_DBG("failed\n"); return false; } #ifdef ARDUINO_ARCH_ESP8266 AC_DBG("DHCP client(%s)\n", wifi_station_dhcpc_status() == DHCP_STOPPED ? "STOPPED" : "STARTED"); #endif // Set host name if (_apConfig.hostName.length()) WiFi.hostname(_apConfig.hostName.c_str()); // If the portal is requested promptly skip the first WiFi.begin and // immediately start the portal. if (_apConfig.immediateStart) { cs = false; _apConfig.autoReconnect = false; _apConfig.autoRise = true; AC_DBG("Start the portal immediately\n"); } else { // Try to connect by STA immediately. if (ssid == nullptr && passphrase == nullptr) WiFi.begin(); else { _disconnectWiFi(false); WiFi.begin(ssid, passphrase); } AC_DBG("WiFi.begin(%s%s%s)\n", ssid == nullptr ? "" : ssid, passphrase == nullptr ? "" : ",", passphrase == nullptr ? "" : passphrase); cs = _waitForConnect(_portalTimeout) == WL_CONNECTED; } // Reconnect with a valid credential as the autoReconnect option is enabled. if (!cs && _apConfig.autoReconnect && (ssid == nullptr && passphrase == nullptr)) { // Load a valid credential. if (_loadAvailCredential()) { // Try to reconnect with a stored credential. AC_DBG("Past SSID:%s loaded\n", reinterpret_cast(_credential.ssid)); const char* psk = strlen(reinterpret_cast(_credential.password)) ? reinterpret_cast(_credential.password) : nullptr; WiFi.begin(reinterpret_cast(_credential.ssid), psk); AC_DBG("WiFi.begin(%s%s%s)\n", _credential.ssid, psk == nullptr ? "" : ",", psk == nullptr ? "" : psk); cs = _waitForConnect(_portalTimeout) == WL_CONNECTED; } } _currentHostIP = WiFi.localIP(); // Rushing into the portal. if (!cs) { // The captive portal is effective at the autoRise is valid only. if (_apConfig.autoRise) { // Change WiFi working mode, Enable AP with STA WiFi.setAutoConnect(false); _disconnectWiFi(false); // Activate the AP mode with configured softAP and start the access point. WiFi.mode(WIFI_AP_STA); while (WiFi.getMode() != WIFI_AP_STA) yield(); // Connection unsuccessful, launch the captive portal. if (!(_apConfig.apip == IPAddress(0, 0, 0, 0) || _apConfig.gateway == IPAddress(0, 0, 0, 0) || _apConfig.netmask == IPAddress(0, 0, 0, 0))) { if (!_config()) { AC_DBG("APConfig failed\n"); } } WiFi.softAP(_apConfig.apid.c_str(), _apConfig.psk.c_str(), _apConfig.channel, _apConfig.hidden); while (WiFi.softAPIP() == IPAddress(0, 0, 0, 0)) yield(); _currentHostIP = WiFi.softAPIP(); AC_DBG("SoftAP %s/%s CH(%d) H(%d) IP:%s\n", _apConfig.apid.c_str(), _apConfig.psk.c_str(), _apConfig.channel, _apConfig.hidden, _currentHostIP.toString().c_str()); // Fork to the exit routine that starts captive portal. cs = _onDetectExit ? _onDetectExit(_currentHostIP) : true; // Start Web server when TCP connection is enabled. _startWebServer(); // Start captive portal without cancellation by DetectExit. if (cs) { // Prepare for redirecting captive portal detection. // Pass all URL requests to _captivePortal to disguise the captive portal. _startDNSServer(); // Start the captive portal to make a new connection while (WiFi.status() != WL_CONNECTED && !_rfReset) { handleClient(); // Force execution of queued processes. yield(); } cs = WiFi.status() == WL_CONNECTED; // If WLAN successfully connected, release DNS server. if (cs) { _dnsServer->stop(); _dnsServer.reset(); } } } } // It doesn't matter the connection status for launching the Web server. if (!_responsePage) _startWebServer(); return cs; } /** * Configure AutoConnect portal access point. * @param ap SSID for access point. * @param psk Password for access point. */ bool AutoConnect::config(const char* ap, const char* password) { _apConfig.apid = String(ap); _apConfig.psk = String(password); return _config(); } /** * Configure AutoConnect portal access point. * @param Config AutoConnectConfig class instance. */ bool AutoConnect::config(AutoConnectConfig& Config) { _apConfig = Config; return _config(); } /** * Configure access point. * Set up access point with internal AucoConnectConfig parameter corrected * by Config method. */ bool AutoConnect::_config() { return WiFi.softAPConfig(_apConfig.apip, _apConfig.gateway, _apConfig.netmask); } /** * Put a user site's home URI. * The URI specified by home is linked from "HOME" in the AutoConnect * portal menu. * @param uri A URI string of user site's home. */ void AutoConnect::home(String uri) { _apConfig.homeUri = uri; } /** * Stops AutoConnect captive portal service. */ void AutoConnect::end() { if (_responsePage != nullptr) { _responsePage->~PageBuilder(); delete _responsePage; } if (_currentPageElement != nullptr) _currentPageElement->~PageElement(); _stopPortal(); if (_webServer) { switch (_webServerAlloc) { case AC_WEBSERVER_HOSTED: if (_dnsServer) _dnsServer.reset(); _webServer.reset(); break; case AC_WEBSERVER_PARASITIC: _webServer.release(); break; } } } /** * Returns the current hosted ESP8266WebServer. */ WebServerClass& AutoConnect::host() { return *_webServer; } /** * Returns AutoConnectAux instance of specified. * @param uri An uri string. * @return A pointer of AutoConnectAux instance. */ AutoConnectAux* AutoConnect::aux(const String& uri) const { AutoConnectAux* aux_p = _aux.get(); while (aux_p) { if (!strcmp(aux_p->uri(), uri.c_str())) break; aux_p = aux_p->_next.get(); } return aux_p; } /** * Append auxiliary pages made up with AutoConnectAux. * @param aux A reference to AutoConnectAux that made up * the auxiliary page to be added. */ bool AutoConnect::join(AutoConnectAux& aux) { if (_aux) _aux->_concat(aux); else _aux.reset(&aux); aux._join(*this); AC_DBG("%s on hands\n", aux.uri()); return true; } /** * Append auxiliary pages made up with AutoConnectAux. * @param aux A vector of reference to AutoConnectAux that made up * the auxiliary page to be added. */ bool AutoConnect::join(std::vector> aux) { for (std::size_t n = 0; n < aux.size(); n++) { AutoConnectAux& addon = aux[n].get(); join(addon); } return true; } /** * Starts Web server for AutoConnect service. */ void AutoConnect::_startWebServer() { // Boot Web server if (!_webServer) { // Only when hosting WebServer internally _webServer.reset(new WebServerClass(AUTOCONNECT_HTTPPORT)); _webServerAlloc = AC_WEBSERVER_HOSTED; AC_DBG("WebServer allocated\n"); } // Discard the original the not found handler to redirect captive portal detection. // It is supposed to evacuate but ESP8266WebServer::_notFoundHandler is not accessible. _webServer->onNotFound(std::bind(&AutoConnect::_handleNotFound, this)); // here, Prepare PageBuilders for captive portal _responsePage = new PageBuilder(); _responsePage->chunked(PB_ByteStream); _responsePage->exitCanHandle(std::bind(&AutoConnect::_classifyHandle, this, std::placeholders::_1, std::placeholders::_2)); _responsePage->insert(*_webServer); _webServer->begin(); AC_DBG("http server started\n"); } /** * Starts DNS server for Captive portal. */ void AutoConnect::_startDNSServer() { // Boot DNS server, set up for captive portal redirection. if (!_dnsServer) { _dnsServer.reset(new DNSServer()); _dnsServer->setErrorReplyCode(DNSReplyCode::NoError); _dnsServer->start(AUTOCONNECT_DNSPORT, "*", WiFi.softAPIP()); AC_DBG("DNS server started\n"); } } /** * Handling for the AutoConnect web interface. * Invoke the handleClient of parent web server to process client request of * AutoConnect WEB interface. * No effects when the web server is not available. */ void AutoConnect::handleClient() { // Is there DNS Server process next request? if (_dnsServer) _dnsServer->processNextRequest(); // handleClient valid only at _webServer activated. if (_webServer) _webServer->handleClient(); handleRequest(); } /** * Handling for the AutoConnect menu request. */ void AutoConnect::handleRequest() { // Handling processing requests to AutoConnect. if (_rfConnect) { // Leave from the AP currently. if (WiFi.status() == WL_CONNECTED) _disconnectWiFi(true); // An attempt to establish a new AP. AC_DBG("Request for %s\n", reinterpret_cast(_credential.ssid)); WiFi.begin(reinterpret_cast(_credential.ssid), reinterpret_cast(_credential.password), _apConfig.channel); if (_waitForConnect(_portalTimeout) == WL_CONNECTED) { if (WiFi.BSSID() != NULL) { memcpy(_credential.bssid, WiFi.BSSID(), sizeof(station_config::bssid)); _currentHostIP = WiFi.localIP(); _redirectURI = String(AUTOCONNECT_URI_SUCCESS); // Save current credential if (_apConfig.autoSave == AC_SAVECREDENTIAL_AUTO) { AutoConnectCredential credit(_apConfig.boundaryOffset); credit.save(&_credential); AC_DBG("%s credential saved\n", reinterpret_cast(_credential.ssid)); } } else AC_DBG("%s has no BSSID, saving is unavailable\n", reinterpret_cast(_credential.ssid)); } else { _currentHostIP = WiFi.softAPIP(); _redirectURI = String(AUTOCONNECT_URI_FAIL); _rsConnect = WiFi.status(); _disconnectWiFi(false); while (WiFi.status() != WL_IDLE_STATUS) { delay(10); yield(); } } _rfConnect = false; } if (_rfReset) { // Reset or disconnect by portal operation result _stopPortal(); AC_DBG("Reset\n"); delay(1000); SOFT_RESET(); delay(1000); } if (_rfDisconnect) { // Disconnect from the current AP. _stopPortal(); _disconnectWiFi(false); while (WiFi.status() == WL_CONNECTED) { delay(10); yield(); } AC_DBG("Disconnected\n"); // Reset disconnection request _rfDisconnect = false; if (_apConfig.autoReset) { delay(1000); SOFT_RESET(); delay(1000); } } } /** * Register the exit routine for AutoConnectAux. * @param uri Specify the URI of the AutoConnectAux page that * registers the exit routine. * @param handler A handler function of the exit routine. * @param order Specify an enumeration type of * AutoConnectExitOrder_t for the call timing of the exit routine. * @return true An exit routine registered. * @return false AutoConnectAux page for the specified URI is not * registered. */ bool AutoConnect::on(const String& uri, const AuxHandlerFunctionT handler, AutoConnectExitOrder_t order) { AutoConnectAux* aux = _aux.get(); while (aux) { if (!strcmp(uri.c_str(), aux->uri())) { aux->on(handler, order); return true; } aux = aux->_next.get(); } return false; } /** * Register the exit routine for the starting captive portal. * @param fn A function of the exit routine. */ void AutoConnect::onDetect(DetectExit_ft fn) { _onDetectExit = fn; } /** * Register the handler function for undefined url request detected. * @param fn A function of the not found handler. */ void AutoConnect::onNotFound(WebServerClass::THandlerFunction fn) { _notFoundHandler = fn; } /** * Load stored credentials that match nearby WLANs. * @retval true A matched credential of BSSID was loaded. */ bool AutoConnect::_loadAvailCredential() { AutoConnectCredential credential(_apConfig.boundaryOffset); if (credential.entries() > 0) { // Scan the vicinity only when the saved credentials are existing. WiFi.scanDelete(); int8_t nn = WiFi.scanNetworks(false, true); AC_DBG("%d network(s) found\n", (int)nn); if (nn > 0) { // Determine valid credentials by BSSID. for (uint8_t i = 0; i < credential.entries(); i++) { credential.load(i, &_credential); for (uint8_t n = 0; n < nn; n++) { if (!memcmp(_credential.bssid, WiFi.BSSID(n), sizeof(station_config::bssid))) return true; } } } } return false; } /** * Disconnect from the AP and stop the AutoConnect portal. * Stops DNS server and flush tcp sending. */ void AutoConnect::_stopPortal() { if (_dnsServer && _webServerAlloc == AC_WEBSERVER_HOSTED) _dnsServer->stop(); if (_webServer) { _webServer->client().stop(); delay(1000); } WiFi.softAPdisconnect(false); AC_DBG("SoftAP stopped\n"); } /** * Redirect to captive portal if we got a request for another domain. * Return true in that case so the page handler do not try to handle the request again. */ bool AutoConnect::_captivePortal() { String hostHeader = _webServer->hostHeader(); if (!_isIP(hostHeader) && (hostHeader != WiFi.localIP().toString())) { String location = String("http://") + _webServer->client().localIP().toString() + String(AUTOCONNECT_URI); _webServer->sendHeader("Location", location, true); _webServer->send(302, "text/plain", ""); _webServer->client().flush(); _webServer->client().stop(); return true; } return false; } /** * A handler that redirects access to the captive portal to the connection * configuration page. */ void AutoConnect::_handleNotFound() { if (!_captivePortal()) { if (_notFoundHandler) { _notFoundHandler(); } else { PageElement page404(_PAGE_404, { { "HEAD", std::bind(&AutoConnect::_token_HEAD, this, std::placeholders::_1) } }); String html = page404.build(); _webServer->sendHeader("Cache-Control", "no-cache, no-store, must-revalidate", true); _webServer->sendHeader("Pragma", "no-cache"); _webServer->sendHeader("Expires", "-1"); _webServer->sendHeader("Content-Length", String(html.length())); _webServer->send(404, "text/html", html); } } } /** * Reset the ESP8266 module. * It is called from the PageBuilder of the disconnect page and indicates * the request for disconnection. It will be into progress after handleClient. */ String AutoConnect::_induceReset(PageArgument& args) { _rfReset = true; return String("Reset in progress..."); } /** * Disconnect from AP. * It is called from the PageBuilder of the disconnect page and indicates * the request for disconnection. It will be into progress after handleClient. */ String AutoConnect::_induceDisconnect(PageArgument& args) { _rfDisconnect = true; return ""; } /** * Indicates a connection establishment request and returns a redirect * response to the waiting for connection page. This is called from * handling of the current request by PageBuilder triggered by handleClient(). * If "Credential" exists in POST parameter, it reads from EEPROM. * @param args http request arguments. * @retval A redirect response including "Location:" header. */ String AutoConnect::_induceConnect(PageArgument& args) { // Retrieve credential from the post method content. if (args.hasArg(AUTOCONNECT_PARAMID_CRED)) { // Read from EEPROM AutoConnectCredential credential(_apConfig.boundaryOffset); struct station_config entry; credential.load(args.arg(AUTOCONNECT_PARAMID_CRED).c_str(), &entry); strncpy(reinterpret_cast(_credential.ssid), reinterpret_cast(entry.ssid), sizeof(_credential.ssid)); strncpy(reinterpret_cast(_credential.password), reinterpret_cast(entry.password), sizeof(_credential.password)); AC_DBG("Credential loaded:%s %s\n", _credential.ssid, _credential.password); } else { // Credential had by the post parameter. strncpy(reinterpret_cast(_credential.ssid), args.arg(AUTOCONNECT_PARAMID_SSID).c_str(), sizeof(_credential.ssid)); strncpy(reinterpret_cast(_credential.password), args.arg(AUTOCONNECT_PARAMID_PASS).c_str(), sizeof(_credential.password)); } // Turn on the trigger to start WiFi.begin(). _rfConnect = true; // Redirect to waiting URI while executing connection request. //_webServer->sendHeader("Location", String("http://") + _webServer->client().localIP().toString() + String(AUTOCONNECT_URI_RESULT), true); String url = String("http://") + _webServer->client().localIP().toString() + String(AUTOCONNECT_URI_RESULT); _webServer->sendHeader("Location", url, true); //_webServer->sendHeader("Connection", "keep-alive"); _webServer->send(302, "text/plain", ""); _webServer->client().flush(); _webServer->client().stop(); _responsePage->cancel(); return ""; } /** * Responds response as redirect to the connection result page. * A destination as _redirectURI is indicated by loop to establish connection. */ String AutoConnect::_invokeResult(PageArgument& args) { String redirect = String("http://") + _currentHostIP.toString() + _redirectURI; _webServer->sendHeader("Location", redirect, true); _webServer->send(302, "text/plain", ""); _webServer->client().flush(); _webServer->client().stop(); _responsePage->cancel(); return ""; } /** * Classify the requested URI to responsive page builder. * There is always only one PageBuilder instance that can exist in * AutoConnect for saving RAM. Invokes a subordinate function that * dynamically generates a response page at handleRequest. This is * a part of the handling of http request originated from handleClient. */ bool AutoConnect::_classifyHandle(HTTPMethod method, String uri) { AC_DBG("Host:%s, URI:%s", _webServer->hostHeader().c_str(), uri.c_str()); // When handleClient calls RequestHandler, the parsed http argument remains // the previous request. // If the current request argument contains AutoConnectElement, it is // the form data of the AutoConnectAux page and with this timing save // the value of each element. if (_webServer->hasArg(String(AUTOCONNECT_AUXURI_PARAM))) { String auxUri = _webServer->arg(AUTOCONNECT_AUXURI_PARAM); auxUri.replace(String("/"), String("/")); AutoConnectAux* aux = _aux.get(); while (aux) { if (aux->_uriStr == auxUri) { aux->_storeElements(_webServer.get()); break; } aux = aux->_next.get(); } } // Here, classify requested uri if (uri == _uri) { AC_DBG_DUMB(", already allocated\n"); return true; // The response page already exists. } // Dispose decrepit page _responsePage->clearElement(); if (_currentPageElement != nullptr) delete _currentPageElement; _uri = String(""); // Create the page dynamically if ((_currentPageElement = _setupPage(uri)) == nullptr) if (_aux) { // Requested URL is not a normal page, exploring AUX pages _currentPageElement = _aux->_setupPage(uri); } if (_currentPageElement != nullptr) { AC_DBG_DUMB(", generated:%s", uri.c_str()); _uri = uri; _responsePage->addElement(*_currentPageElement); _responsePage->setUri(_uri.c_str()); } AC_DBG_DUMB(", %s\n", _currentPageElement != nullptr ? "allocated" : "ignored"); return _currentPageElement != nullptr ? true : false; } /** * It checks whether the specified character string is a valid IP address. * @param ipStr IP string for validation. * @return true Valid. */ bool AutoConnect::_isIP(String ipStr) { for (uint8_t i = 0; i < ipStr.length(); i++) { char c = ipStr.charAt(i); if (c != '.' && (c < '0' || c > '9')) return false; } return true; } /** * Convert MAC address in uint8_t array to Sting XX:XX:XX:XX:XX:XX format. * @param mac Array of MAC address 6 bytes. * @retval MAC address string in XX:XX:XX:XX:XX:XX format. */ String AutoConnect::_toMACAddressString(const uint8_t mac[]) { String macAddr = ""; for (uint8_t i = 0; i < 6; i++) { char buf[3]; sprintf(buf, "%02X", mac[i]); macAddr += buf; if (i < 5) macAddr += ':'; } return macAddr; } /** * Convert dBm to the wifi signal quality. * @param rssi dBm. * @retval a signal quality percentage. */ unsigned int AutoConnect::_toWiFiQuality(int32_t rssi) { unsigned int qu; if (rssi == 31) // WiFi signal is weak and RSSI value is unreliable. qu = 0; else if (rssi <= -100) qu = 0; else if (rssi >= -50) qu = 100; else qu = 2 * (rssi + 100); return qu; } /** * Wait for establishment of the connection until the specified time expires. * @param timeout Expiration time by millisecond unit. * @retval wl_status_t */ wl_status_t AutoConnect::_waitForConnect(unsigned long timeout) { wl_status_t wifiStatus; AC_DBG("Connecting"); unsigned long st = millis(); while ((wifiStatus = WiFi.status()) != WL_CONNECTED) { if (timeout) { if (millis() - st > timeout) break; } AC_DBG_DUMB("%c", '.'); delay(300); } AC_DBG_DUMB("%s IP:%s\n", wifiStatus == WL_CONNECTED ? "established" : "time out", WiFi.localIP().toString().c_str()); return wifiStatus; } /** * Disconnects the station from an associated access point. * @param wifiOff The station mode turning switch. */ void AutoConnect::_disconnectWiFi(bool wifiOff) { #if defined(ARDUINO_ARCH_ESP8266) WiFi.disconnect(wifiOff); #elif defined(ARDUINO_ARCH_ESP32) WiFi.disconnect(wifiOff, true); #endif while (WiFi.status() == WL_CONNECTED) delay(100); }