Helpful? spread the word!

This tutorial demonstrates how to control ESP8266 NodeMCU boards from custom multi-platform apps that work on Desktop, iOS and Android.  The example app will allow us to turn on and off the ESP8266 NodeMCU built-in LED.

We will configure an asynchronous web server and develop an API REST in an Arduino sketch. Then, we will develop a multi-platform app using Felgo Standard Development Kit (SDK). Felgo leverages Qt (Qt Creator, QML, Qt Quick, JavaScript, etc.) for the development of multi-platform apps.

Background

This example is introductory and easy to follow. If you are not familiar with all the jargon in the previous paragraph, don’t fell overwhelmed! below you can find tutorials that will help you. Check them if you are not familiar with Arduino IDE or Felgo.

Hardware

Amazon: NodeMCU V2
Amazon: NodeMCU V3

These are affiliate links. This means if you click on the link and purchase the promoted item, we will receive a small affiliate commission at no extra cost to you, the price of the product is the same. We would really appreciate your support to our work and website if this is fine for you.

Outline

  • ESP8266 NodeMCU sketch
  • Custom app for Desktop, iOS and Android

ESP8266 NodeMCU sketch

We could use the default ESP8266WebServer, but the ESPAsyncWebServer has some advantage over the previous one because it is an asynchronous web server, and therefore it can deal with several clients. Also, API calls can be more concisely defined. So, let’s see how we can make use of it.

We need to install the following libraries for our sketch. If you don’t know how to install them in the Arduino IDE, have a look at the end of ESP8266 NodeMCU programming: First Steps.

  • ArduinoJson (version 5). It can be installed from the Arduino IDE library manager.
  • ESPAsyncTCP. Download the repository as a zip file and install it through the Arduino IDE.
  • ESPAsyncWebServer. Download the repository as a zip file and install it through the Arduino IDE.

Let’s have a look at the sketch code. The first part includes some header files, defines some constants (WiFi identifier and password, server port, request URL and parameter) and declares our server object.

#include <ESP8266WiFi.h>
#include <ESPAsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>

const char ssid[] = "WiFi_SSID";
const char pass[] = "WiFi_password";
const int  port   = 2390;

const String REQUEST_LED = "/led";
const String PARAM_STATE = "state";

AsyncWebServer server(port);

Next, we define two helper functions to get and set the built-in LED state. Notice that the built-in LED is on at LOW state.

void setLEDstate(bool state) {
  digitalWrite(LED_BUILTIN, state ? LOW : HIGH);
}

bool getLEDstate() {
  return !digitalRead(LED_BUILTIN);
}

Our web server is asynchronous, so we have to define all its behavior in the setup function, our loop function is therefore empty in this example. The first part of the setup function sets the data transmission rate, declares the built-in LED pin as output, turns it off, connects to our WiFi network and prints the WiFi SSID and IP address to the serial monitor.

void setup() {

  Serial.begin(115200);

  pinMode(LED_BUILTIN, OUTPUT);
  digitalWrite(LED_BUILTIN, HIGH);

  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, pass);
  Serial.print("Connecting ");
  while (WiFi.status() != WL_CONNECTED) {
    Serial.print(".");
    delay(300);
  }

  Serial.println("");
  Serial.print("WiFi connected to: ");
  Serial.println(WiFi.SSID());
  Serial.print("IP address: ");
  Serial.println(WiFi.localIP());

  .....
  .....
}

void loop() {}

We can declare server.on statements in our sketch to intercept requests. The following code shows how we can intercept GET (HTTP_GET) requests to <IP>/led. REQUEST_LED is a string constant (“/led”)  that is converted to char * constant using the c_str function.

Then, we check if PARAM_STATE parameter (“state”) is in the request (has_param). If this is the case, we get its value (getParam) as a string and convert it to an integer value (toInt). The integer value is automatically cast to a Boolean value.

After that, we set the LED state (setLEDstate function) according to the state variable. POST requests can be defined in the same way (HTTP_POST) as GET request.

void setup() {
  .....
  .....

  server.on(REQUEST_LED.c_str(), HTTP_GET, [] 
            (AsyncWebServerRequest *request) {
    
    // If state is a parameter then read it and call setLEDstate
    if (request->hasParam(PARAM_STATE)) 
    {
      bool state = request->getParam(PARAM_STATE)->value().toInt();
      setLEDstate(state);
    }

    // Create and fill Json object for client response
    DynamicJsonBuffer jsonBuffer;
    JsonObject &json  = jsonBuffer.createObject();
    json[REQUEST_LED.substring(1)] = getLEDstate();

    // Send Json response to client
    sendJsonResponse(request,json);
    
  });

  .....
  .....
}

To sum up, considering that the ESP8266 NodeMCU IP address is 192.168.1.43, we are expecting the following requests. We can try them in our browser to see that they work.

http://192.168.1.43:2390/led?state=0
http://192.168.1.43:2390/led?state=1

The next step in the server.on statement is to answer the client. To do that, we create a json object with just one variable which describes the LED state: json[‘led’] = true or json[‘led’] = false. Finally, we send the response to the client calling the sendJsonResponse function, we will talk about it later.

If we test the previously mentioned requests in our browser, we will see the json object sent by our asynchronous web server.

{"led":true}
{"led":false}

The last part of the setup function includes a custom function (notFound) that will be called when a request not previous intercepted is performed to our sever (server.onNotFound). The final statement starts our server (server.begin). 

void setup() {
  .....
  .....

  server.onNotFound(notFound);

  server.begin();
}

The notFound function simply sends an error code (404) to the server and the plain text “Not  found”.

void notFound(AsyncWebServerRequest *request) {
    request->send(404, "text/plain", "Not found");
}

The goal of the sendJsonResponse function is to send a json object to the client. To do that, we first create a response object (response), print the json to the response object (printTo) and send it (send).

void sendJsonResponse(AsyncWebServerRequest *request, 
                      JsonObject &json) {
  AsyncResponseStream *response = 
     request->beginResponseStream("application/json");
  json.printTo(*response);
  request->send(response);          
}

The whole sketch code is shown below.

#include <ESP8266WiFi.h>
#include <ESPAsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>

const char ssid[] = "WiFi_SSID";
const char pass[] = "WiFi_password";
const int  port   = 2390;

const String REQUEST_LED = "/led";
const String PARAM_STATE = "state";

AsyncWebServer server(port);

void setLEDstate(bool state) {
  digitalWrite(LED_BUILTIN, state ? LOW : HIGH);
}

bool getLEDstate() {
  return !digitalRead(LED_BUILTIN);
}

void setup() {

  Serial.begin(115200);

  pinMode(LED_BUILTIN, OUTPUT);
  digitalWrite(LED_BUILTIN, HIGH);

  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, pass);
  Serial.print("Connecting ");
  while (WiFi.status() != WL_CONNECTED) {
    Serial.print(".");
    delay(300);
  }

  Serial.println("");
  Serial.print("WiFi connected to: ");
  Serial.println(WiFi.SSID());
  Serial.print("IP address: ");
  Serial.println(WiFi.localIP());

  server.on(REQUEST_LED.c_str(), HTTP_GET, [] 
           (AsyncWebServerRequest *request) {
    
    // If state is a parameter then read it and call setLEDstate
    if (request->hasParam(PARAM_STATE)) 
    {
      bool state = request->getParam(PARAM_STATE)->value().toInt();
      setLEDstate(state);
    }

    // Create and fill json object for client response
    DynamicJsonBuffer jsonBuffer;
    JsonObject &json  = jsonBuffer.createObject();
    json[REQUEST_LED.substring(1)] = getLEDstate();

    // Send Json response to client
    sendJsonResponse(request,json);
    
  });

  server.onNotFound(notFound);

  server.begin();
}

void notFound(AsyncWebServerRequest *request) {
    request->send(404, "text/plain", "Not found");
}

void sendJsonResponse(AsyncWebServerRequest *request, 
                      JsonObject &json) {
  AsyncResponseStream *response = 
     request->beginResponseStream("application/json");
  json.printTo(*response);
  request->send(response);          
}

void loop() {}

Custom App for Desktop, iOS and Android

Our app is programmed in QML and JavaScript using Felgo. Check Qt, QML and Felgo tutorial. NASA Astronomy Picture of the Day app for Desktop, iOS and Android for an introductory tutorial. This app is open source and it’s available in our GitHub account.

Custom app for Desktop, iOS and Android
Custom app for Desktop, iOS and Android

In our app, first we customize our theme (onInitTheme), include a NavigationStack to manage Pages, a custom configuration dialog (ConfDialog), and a Page component. We will describe the custom configuration dialog later. 

In our page, we add an icon button (IconButtonBarItem) in the NavigationBarRow component which is placed on the right-hand side of our navigation bar (rightBarItem). This button opens our custom dialog (ConfDialog).  Our page also includes a background Image that fills the whole page.

import QtQuick 2.0
import Felgo 3.0

App {

    onInitTheme: {
      Theme.colors.tintColor = "#1e73be"
      Theme.navigationBar.backgroundColor = Theme.colors.tintColor
      Theme.navigationBar.titleColor = "white"
      Theme.navigationBar.itemColor  = "white"
    }

    NavigationStack {

        ConfDialog{
            id: confDialog
            onIpChanged: message.text = ""
        }

        Page {
            id: page
            title: "NodeMCU built-in led control"

            rightBarItem:  NavigationBarRow {
              IconButtonBarItem {
                icon: IconType.gear
                onClicked: confDialog.open()
                title: "Configuration"
              }
            }

            Image{
                anchors.fill: parent
                source: "../assets/MTB_background.jpg"
                fillMode: Image.PreserveAspectCrop
                opacity: 0.5
            }

    ......
    ......
}

The components in our page are organized following a column layout. Item elements are used for additional spacing between components. We can see the general structure below.

.....
.....

Page{
   .....
   .....
            
   Column{
      width: parent.width
      spacing: dp(10)

      Item{width: 1; height: dp(10)}

      Image{
         source: "../assets/MTB_logo.png"
         fillMode: Image.PreserveAspectFit
         anchors.horizontalCenter: parent.horizontalCenter
         width: parent.width - dp(40)
      }

      Item{width: 1; height: dp(30)}

      Row{
         anchors.horizontalCenter: parent.horizontalCenter
         spacing: dp(10)

         AppText{
            anchors.verticalCenter: parent.verticalCenter
            text: "LED status"
         }

         Item{width: dp(20); height: 1}

         AppSwitch{
            id: switchLED
            onToggled: {
               if (!confDialog.validateIPaddress(confDialog.ip))
               {
                switchLED.setChecked(false)
                message.color = "red"
                message.text  = "Please, set a valid IP address"
               }
               else request_LED(confDialog.ip,switchLED.checked)
            }
         }

         AppText{
            anchors.verticalCenter: parent.verticalCenter
            text: switchLED.checked ? "ON" : "OFF"
         }

      }

      Item{width: 1; height: dp(30)}

      AppText{
         anchors.horizontalCenter: parent.horizontalCenter
         id: message
      }

      AppActivityIndicator{
         id: indicator
         anchors.horizontalCenter: parent.horizontalCenter
         color: Theme.tintColor
         animating: false
         visible: false
      }
 }

When the switch is toggled (onToggled), we check if the IP address introduced in our custom dialog (confDialog) is valid, calling the confDialog.validateIPaddress function. If the IP address is valid, then the request_LED JavaScript function is called. Otherwise an error message is shown.

function request_LED(ip,state)
{
   const port        = 2390
   const request_ON  = "1"
   const request_OFF = "0"
   const led_uri     = "/led"
   const Http_OK     = 200
   const timeout_ms  = 5000

   var url    = "http://" + ip + ":" + port + led_uri
   var pState = state ? request_ON : request_OFF
   var params = "state="+pState

   message.text = ""
   indicator.visible = true
   indicator.startAnimating()

   HttpRequest
      .get(url + "?" + params)
      .timeout(timeout_ms)
       .then(function(res)
       {
          if (res.status === Http_OK)
             if (requestSuccess(res.body)) return
          requestError()
       })
       .catch(function(err)
       {
          requestError()
       });
 }

The request_led functions first enables the activity indicator. After that, it makes use of the HttpRequest object to perform an HTTP request to our ESP82266 NodeMCU board. If the request is successful, the requestSuccess function is called, otherwise the requestError function is executed.

The requestSuccess function stops and hides the activity indicator and shows the LED remote state according to the information received from the server in the json object.

function requestSuccess(res_json)
{
   message.color = "green"
   message.text  = "Remote LED status " + 
                   (res_json["led"] ? "ON" : "OFF")
   indicator.stopAnimating()
   indicator.visible = false
   return true
}

The requestError function also stops and hides the activity indicator, but it shows an error message to the user.

function requestError()
{
   message.color = "red"
   message.text  = "Connection error"
   indicator.stopAnimating()
   indicator.visible = false
}

Our custom dialog has three elements organized in a column layout.

  • Column
    • AppText – The text “IP Address”
    • AppTextField – To introduce the IP address
    • AppText – To show an error message
QML custom dialog
Custom dialog

By default a custom dialog has two buttons. Their labels are customized by overwriting the positiveActionLabel and negativeActionLabel properties.

When the accepted button is clicked (onAccepted), it is checked if the IP address is valid (validateIPaddress), we use a JavaScript regular expression to do that. If it is invalid, an error message is shown (errorMessage function).

If the IP address is valid, we use an Storage component to permanently store it, we only need an string identifier (key_IP) and an associated value. This allows us to retrieve this information the next time the app is executed, so the user don’t have to type the same IP address again. Have a look at the Storage Component.onCompleted handler for the implementation details.

import QtQuick 2.11
import Felgo 3.0

Dialog {
    id:       confDialog
    title:    "Configuration"
    positiveActionLabel: "Done"
    negativeActionLabel: "Cancel"
    outsideTouchable: false

    readonly property string key_IP: "IP"
    property string ip

    onAccepted: {
        if (validateIPaddress(ipAddress.text))
        {
            storage.setValue(key_IP,ipAddress.text)
            ip = ipAddress.text
            close()
        }
        else errorMessage()
    }

    onCanceled: close()

    onIsOpenChanged: {
        if (isOpen) ipAddress.text = ip
    }

    Column {
        id: column
        anchors.fill:  parent
        spacing:       dp(10)
        topPadding:    dp(30)
        bottomPadding: dp(10)

        AppText{
            anchors.horizontalCenter: parent.horizontalCenter
            text: "IP address"
        }

        AppTextField{
           id: ipAddress
           anchors.horizontalCenter: parent.horizontalCenter
           horizontalAlignment: TextInput.AlignHCenter
           placeholderColor: "lightgray"
           placeholderText: "E.g: 192.168.0.2"
           borderColor: Theme.tintColor
           borderWidth: 2
           inputMethodHints: Qt.ImhPreferNumbers
           onTextChanged: message.text=""
           radius: dp(20)
        }

        AppText{
            id: message
            anchors.horizontalCenter: parent.horizontalCenter
            color: "red"
        }
    }

    Storage{
        id: storage

        Component.onCompleted: {
            ipAddress.text = getValue(key_IP) === undefined ? 
                "" : getValue(key_IP)
            ip = ipAddress.text
        }
    }

    function validateIPaddress(ipaddress)
    {
      if (/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4] 
          [0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9] 
          [0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0- 
          9]?)$/.test(ipaddress))
         return true
      return false
    }

    function errorMessage()
    {
        message.text = "Invalid IP address"
    }
}

I hope you enjoyed this tutorial! if you have any question, doubt or comment, please write below. Stay tuned to Mechatronics Blog because in the following weeks we will use this asynchronous web server to interact with more complex and fun Arduino electronic components.

1
Leave a Reply

avatar
2000
1 Comment threads
0 Thread replies
0 Followers
 
Most reacted comment
Hottest comment thread
0 Comment authors
Recent comment authors
  Subscribe  
newest oldest most voted
Notify of
trackback

[…] This tutorial demonstrates how to control ESP8266 NodeMCU boards from custom multi-platform apps that work on Desktop, iOS and Android.  […]

shares