Close

ESP NOW Implementation

A project log for ESP-NOW Weather Station

An ESP32 based weather station investigating ESP32's Deep Sleep and ESP-NOW

Kevin KesslerKevin Kessler 09/23/2020 at 14:330 Comments

The basic design of the Weather Station is a set of outside sensors, run by an ESP32 whose values are transmitted to a base station inside. The data is transmitted through ESP NOW, and when received by the base station, that data will be unpacked and dropped on an MQTT broker, where it can be consumed by Home Assistant, Node Red, Grafana, and anything else that might be fun.

There are many good ESP NOW introductions and one of the best I found is right here on Hackaday.ioHello World for ESP-NOW. It gets trickier when you need to have ESP NOW and WiFi coexist at the same time on the ESP32, which is what needs to happen for the Base Station to operate properly. This coexistence is not supported on the ESP8266.

For most of my projects, I like to include ArduinoOTA and WiFiManager. ArduinoOTA allows for Over-The-Air flash of firmware to the ESP32. It is very simple to setup and once initialized, it just requires you to call

ArduinoOTA.handle();

in the loop(). Flashing can be done in PlatformIO by specifying the hostname as upload_port in platform.ini, and can be done through the Arduino IDE as well. This does not interfere with ESP NOW and can be used normally.

WiFiManager is a nice way to configure your WiFi credentials, and other configuration elements. In it's simplest form, in the setup code you call WiFiManager.autoConnect() and, if the WiFi credentials are already stored in flash, the ESP will connect to WiFi. If there are no credentials stored in flash, the WiFiManager will start up an Access Point to which you can connect on a device like your cell phone. Once connect to the access point, you bring up a browser and go to address 192.168.4.1 and a configuration menu is displayed where you can enter your WiFi credentials, which are then stored and used to connect to WiFi on all subsequent connection attempts. 

For a little more complex scenario, you can add configuration elements to the menu by creating a WiFiManagerParameter and then using WiFiManager.addParameter() to put these parameters on the configuration menu. For example, to get the MQTT Server configuration, I use the following code to setup the parameters:

  WiFiManagerParameter mqtt_server("server", "MQTT Server", mqttServer, MQTT_SERVER_LENGTH);
  char port_string[6];
  itoa(mqttPort, port_string,10);
  WiFiManagerParameter mqtt_port("port", "MQTT port", port_string, 6);
  WiFiManagerParameter mqtt_topic("topic", "MQTT Topic", mqttTopic,MQTT_TOPIC_LENGTH);

  wfm.addParameter(&mqtt_server);
  wfm.addParameter(&mqtt_port);
  wfm.addParameter(&mqtt_topic);

When the configuration portal is show, these 3 new parameters will be on the form.  When the form is submitted, the values for the MQTT server and be retrieved with

  strncpy(mqttServer, mqtt_server.getValue(), MQTT_SERVER_LENGTH);
  strncpy(mqttTopic, mqtt_topic.getValue(), MQTT_TOPIC_LENGTH);
  mqttPort = atoi(mqtt_port.getValue());

It is important to note that these custom parameters are not stored automatically, like the WiFi credentials are. You must store these values in EEPROM programatically if you wish to to have them the next time the ESP is rebooted.

When using ESP NOW, you cannot use WiFiManager.autoConnect() to manage the WiFi connection because you need a little more control of the connections to have both WiFi and ESP NOW coexist. You can, though, use WiFiManager to handle the parameters, because you can call WiFiManager.startConfigPortal manually.  Since the WiFi credentials are stored at the same location that the ESPs store them normally, after the portal stores the credentials, a standard WiFi.begin() will use them. I have the base station programmed to automatically bring up the portal if the MQTT information is missing in EEPROM, and I have also configured a button which start the portal on demand if the button is pressed more than 1 second.

In order for 2 ESPs to communicate over ESP NOW, they both must be transmitting and listening on the same WiFi Channel. For the station that is using both WiFi and ESP NOW both protocols must use the same channel because having WiFi use one Channel and ESP NOW use another is not supported. Since the channel that WiFi uses is determined by the WiFi router in the home, that same channel is used for ESP NOW and you really can't pre-configure it. In order to communicate this channel to the other end of the ESP NOW communication, the base station can start a soft access point.

  WiFi.mode(WIFI_AP_STA);
  
  WiFi.begin();
  if(WiFi.softAP(STATION_NAME,"1234567890",1,0))
  {
    Serial.println("Soft AP Success)");
  } else {
    Serial.println("SoftAP Fail");
  }

  while (WiFi.status() != WL_CONNECTED) {
    delay(1000);
    Serial.println("Establishing connection to WiFi..");
  }

The trick here is that the WiFi.mode must be WIFI_AP_STA, even if you are not starting an access point, or you can't get WiFi and ESP NOW to simultaneously function. That took a while for me to figure out. In this case, it doesn't matter, since you will be connecting both as a station, and you will be bringing up an access point. Once the mode is set, WiFi.begin() connects to the home wifi router configured earlier with the WiFiManager. It then starts the softAP.

Once this is done, ESP NOW is initialized by

  esp_now_init();

  esp_now_register_recv_cb(espnow_recv_cb);

Once the base station is up and broadcasting it's SSID, the outside ESP32 can look for the base station SSID and get its WiFi Channel and mac address.

  WiFi.mode(WIFI_STA);
  WiFi.disconnect();

  if(scan) {
    espnow_channel = DEFAULT_WIFI_CHANNEL;
    memcpy(macAddr, broadcast_mac, 6);  

    uint8_t networks = WiFi.scanNetworks();
    for(int n=0;n<networks;n++) {
        Serial.printf("%d %s   %d   %d %s \n", n,WiFi.SSID(n).c_str(),WiFi.RSSI(n), WiFi.channel(n), WiFi.BSSIDstr(n).c_str());
        for(int i=0;i<8;i++) {
            Serial.print(WiFi.BSSID(n)[i]);
            Serial.print(" ");
        }
        Serial.println();

        if(WiFi.SSID(n).indexOf(STATION_NAME) == 0)
        {
            Serial.println("Found");
            memcpy(macAddr, WiFi.BSSID(n), 6);
            espnow_channel = WiFi.channel(n);
        }
        
    }

    WiFi.scanDelete();
  }

It first sets the mode to WIFI_STA, which works when only ESP NOW must be supported, and disconnects anything from WiFi to make sure everything is clean. It first setup some default values for the channel and address in case it doesn't find the base station SSID, which are channel 3 (which is what my AP is on) and the mac broadcast address of 0xFFFFFFFFFFFF. 

The SSID scan is initiated and the code looks for the SSID of the base station (STATION_NAME= "WeatherBase"). If it find it, it copies it's mac address and channel into global variables with the RTC_DATA_ATTR attribute on them, so they survive a deep sleep. When the wakes from deep sleep, this function is call with scan=false, so the scan is not preformed and the saved values are used. The WiFI.scanDelete() frees up the memory used by the WiFi scanNetworks().

  esp_wifi_set_promiscuous(true);
  esp_wifi_set_channel(espnow_channel,WIFI_SECOND_CHAN_NONE);
  esp_wifi_set_promiscuous(false);

  if (esp_now_init() != 0)
  {
    return;
  }

  esp_now_peer_info_t peer_info;
  peer_info.channel = espnow_channel;
  memcpy(peer_info.peer_addr, macAddr, 6);
  peer_info.encrypt = false;
  peer_info.ifidx = ESP_IF_WIFI_STA;
  esp_err_t status = esp_now_add_peer(&peer_info);
  if (ESP_OK != status)
  {
    Serial.println("Could not add peer");
    handle_error(status);
  }

  status = esp_now_register_send_cb(msg_send_cb);
  if (ESP_OK != status)
  {
    Serial.println("Could not register send callback");
    handle_error(status);
  }

With mac address and channel in hand, the channel is set with the turning on promiscuous mode, setting the channel with esp_wifi_set_channel, and turning off promiscuous mode. The target address of the base station is configured as a peer to be used as a target for the ESP NOW transmission.

void send_msg(sensor_data_t * msg)
{
  // Pack
  uint16_t packet_size = sizeof(sensor_data_t);
  uint8_t msg_data[packet_size];
  memcpy(&msg_data[0], msg, sizeof(sensor_data_t));

  esp_err_t status = esp_now_send(macAddr, msg_data, packet_size);
  if (ESP_OK != status)
  {
    Serial.println("Error sending message");
    handle_error(status);
  }
}

After the weather station polls its sensors, the data from the sensors is flattened into an array of bytes, and is transmitted with the esp_now_send call to the base station mac address. The peer_info structure configured in the preceding code snippet is used internally by the ESP code, for if it is not configured correctly, the esp_now_send call will fail.

Once the weather station transmits its data, it goes into deep sleep until the timer wakes it up to do another poll of its sensors. In order to get the lowest sleep current, the WiFi radio must be turned off with

  WiFi.disconnect(true);
  WiFi.mode(WIFI_OFF);

Back on the base station, the receive message call back is called, and the base station unpacks the received bytes back into the sensor data struct.

static void espnow_recv_cb(const uint8_t *mac_addr, const uint8_t *data, int len)
{

  if (len == sizeof(sensor_data_t))
  {
    memcpy(&sensorData, data, len);

    Serial.printf("Temperature=%f *C\n",sensorData.temperature);
    Serial.printf("Pressure=%d Pa\n",sensorData.pressure);
    Serial.printf("Humidity=%f\n",sensorData.humidity);
    Serial.printf("Battery Volts=%f mV\n",sensorData.battery_millivolts);
    Serial.printf("Direction=%d\n",sensorData.direction);
    Serial.printf("Rain Count=%d\n", sensorData.rain_count);
    Serial.printf("Anenomoeter Count=%d\n", sensorData.anemometer_count);
    Serial.printf("Count=%d\n",count++);

    dataValid=true;


  } else {
    Serial.println("Received something of wrong length");
  }
}

For some reason, the MQTT transmission of the data cannot occur in the callback function, so a global variable is set to true, and, in loop(), this variable is continually polled, so that MQTT transmission can occur in the loop context.

void loop() {
  if(buttonLongPress) {
    Serial.println("Config Button");
    callWFM(false);
    buttonLongPress = false;
  }

  // Fails if I send data to MQTT in call back
  if(dataValid) {
    dataValid=false;
    sendMQTTData();
  }

  ArduinoOTA.handle();
}

Discussions