ESP8266, Now Talk!

20 May 2018

Most communications with an ESP8266 use WiFi to talk to computers (or other ESP8266’s) on a LAN, or over the Internet. They use protocol like HTTP to talk to web servers, or SMTP to send emails. These protocols are application level protocols, which sit on top of lower level protocols to form a stack of protocols required to talk across the Internet. For a HTTP connection, it’s really running HTTP over TCP over IP over WiFi.

Being able to talk using the same protocols as other devices on the Internet makes the ESP8266 very versatile for many projects. Sometimes, however, you want to have connectivity without the overhead of a full WiFi stack, or protocols capable of being routed world-wide, you just want a low latency link between a few devices - enter ESP-NOW.

ESP-NOW

ESP-NOW is a special mode of the ESP8266 that works over the 2.4GHz WiFi frequencies and channels, but uses “Action Vendor Frames” to send data in a connectionless manner and without higher level protocols being used. This works between a small number of ESP8266’s only - you can’t use this to talk to your PC, or a server on the Internet. The advantage? Speed. Lots of it. By getting so much of the overhead out of the way, the ESP8266 is able to send messages with <10ms of latency.

The protocol was designed by Espressif to enable small messages (≤ 250 bytes) to be passed between ESP8266’s and ESP32’s. I do not know if ESP8266’s and ESP32’s can communicate with each other via ESP-NOW, as I have no ESP32’s to test against. Peer groups can be set up to allow for messages to be easily sent to a number of ESP8266’s with a single call. The documentation that I have read says that you need to set up these peers, but that doesn’t actually seem to be necessary.

To send data to another ESP8266 via ESP-NOW, you need to know the MAC address of the device you want to talk to - and don’t forget, there are two MAC addresses for every ESP8266, one for the SoftAP interface, one for the Station interface.

In ESP-NOW, you can choose to have each device in one of the following roles:

  • IDLE - not in use
  • CONTROLLER - priority is given to the station interface
  • SLAVE - priority is given to the SoftAP interface
  • COMBO - priority is given to the SoftAP interface

In normal usage, the controller device sends messages to the slave device, using the slave device’s SoftAP MAC address. If you wish to have two way conversations, then change the controller to a “combo” device, due to the following note from Espressif:

1
It is not recommended to send packets to a device in Station-only mode, for the device may be in sleep.

Finally, it is worth noting that when the ESP8266 is in ESP-NOW mode, it cannot use the normal WiFi communications at the same time, you have to disable ESP-NOW to use WiFi connections. If you want to have a WiFi <–> ESP-NOW gateway, I would suggest using two ESP8266’s together, one for ESP-NOW, one for WiFi.

Using ESP-NOW

The way that I am using ESP-NOW is to not use the peers, and explicitly send to a single recipient, all of my code fragments will be from how I have set things up for myself.

To start ESP-NOW, you call the function esp_now_init. Always check the return value for this function, as it can fail. If it does fail, you will have to either try again, restart the ESP8266, or simply print out an error and do nothing. Fortunately, it hasn’t failed for me yet. You must then choose the role that you wish to be running as with:

1
esp_now_set_self_role(ESP_NOW_ROLE_CONTROLLER);

Substituting ESP_NOW_ROLE_SLAVE or ESP_NOW_ROLE_COMBO as appropriate.

There are two call-back functions that you can use to know about message sending/receiving. The first is

1
esp_now_register_recv_cb(func);

Which will call function func when a message is received via ESP-NOW. The second call-back is

1
esp_now_register_send_cb(func);

Which will call function func after sending a message. In this function, you can find out if the transmission was successful or not. Note, however, that this says whether it was sent successfully, not received successfully. The ESP8266 can think it’s been sent OK, but something gone wrong after that and have it not be processed correctly. If you need to know if it’s been processed correctly, use an acknowledgement. I don’t personally bother with this call-back, but it would be helpful if you’re wanting higher reliability.

Once you have ESP-NOW initialised, you are going to want to send a message, but how? Easy, just call:

1
esp_now_send(dest_mac, message, length);

dest_mac is a 6 byte array holding the MAC address that you are sending to. If you don’t know the MAC address, things won’t work. There is a way to do broadcasts using a MAC address that is all 0xFF’s, but I haven’t tried it out yet. I have simply hard-coded the destination address in my code.

When a message is received the receive call-back function you call will be invoked. This function has the following signature:

1
void message_rx_cb(uint8_t *mac, uint8_t *data, uint8_t len)

As the MAC address of the sender is supplied, you can use this value to direct messages back to the receiver without having to hard-code the addresses in both directions. Please note that you shouldn’t perform too much processing in this call-back, as it is called from a critical section of code for WiFi handling. If you want to do some additional work on the result, use a task queue to have it invoked in a much safer context. In my example below, I use this for the reply messages.

ESP-NOW example program

I have put together an example program that I used to test a critical feature of ESP-NOW - it’s speed. I wanted to know how long it took to send a message via ESP-NOW. The code has a “sender” node and a “receiver” node. The sender sends a message to the receiver every second, which contains a simple counter. This counter is then sent back to the sender, so that the sender can be sure it’s the same message (by the counter) and use it to measure the transmission round trip time (RTT). The RTT is how long it takes for the message to go from the sender to the receiver and back again, so it will be double the one-way transmission time.

The RTT durations that I was seeing from my tests were mostly between 7.0-7.5 milliseconds, with occasional delays of up to 11 milliseconds on occasions. This means that each message took less than 4 milliseconds to send most of the time. As I was hoping to be able to use this for an interactive project, <10ms will feel instantaneous to use, which is exactly what I was hoping for.

The initialisation routine is run from a system_init_done_cb function, to ensure that the ESP8266 is ready for ESP-NOW to be initialised, and has the below code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
LOCAL void ICACHE_FLASH_ATTR system_ready_cb() {
	os_printf("In system callback function.\n");

	// Decide if we're an input or an output.
	bool gpio5 = GPIO_INPUT_GET(5);
    if (gpio5) {
		mode = SENDER;
	} else {
		mode = RECEIVER;
	}

	// Print some information over serial.
	uint8_t softap_mac[6];
	uint8_t station_mac[6];
	wifi_get_macaddr(SOFTAP_IF, softap_mac);
	wifi_get_macaddr(STATION_IF, station_mac);
	os_printf("In %s mode.\n", (mode == SENDER) ? "sending" : "receiving");
	os_printf("SoftAP MAC address : "MACSTR"\n", MAC2STR(softap_mac));
	os_printf("Station MAC address: "MACSTR"\n", MAC2STR(station_mac));

	if (esp_now_init()) {
		// We couldn't set up ESP-NOW.
		os_printf("Unable to start ESP-NOW.\n");
	} else {
		// Create a timer for checking if we have missed any packets.
		os_printf("ESP-NOW mode enabled.\n");
		os_timer_disarm(&rx_timer);
		os_timer_setfn(&rx_timer,
				(os_timer_func_t *)message_timeout, (void *)0);
		if (mode == SENDER) {
			// Make the sender a controller.
			esp_now_set_self_role(ESP_NOW_ROLE_CONTROLLER);

			// Start a timer for sending packets every second.
			os_timer_disarm(&tx_timer);
			os_timer_setfn(&tx_timer,
					(os_timer_func_t *)send_message, (void *)0);
			os_timer_arm(&tx_timer, SEND_INTERVAL, 1);
		} else {
			// Make the receiver a slave.
			esp_now_set_self_role(ESP_NOW_ROLE_SLAVE);

			// Set up the system task for replying to messages.
			system_os_task(reply_to_message, REPLY_PRI, reply_queue, REPLY_QUEUE_LEN);

			// Start the receive timer.
			os_timer_arm(&rx_timer, RECEIVER_TIMEOUT_INTERVAL, 0);
		}

		// Set up the callback for receiving messages.
		esp_now_register_recv_cb(message_rx_cb);
	}

	os_printf("Completed system callback function.\n");
}

Sending a message is easy:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
LOCAL void ICACHE_FLASH_ATTR send_message(void *arg) {
	// Prepare the message contents.
	tx_message_count++;
	uint8_t message[6];
	message[0] = 0xAA;
	message[1] = 0xBB;
	message[2] = ((tx_message_count & 0x000000FF));
	message[3] = ((tx_message_count & 0x0000FF00) >> 8)  & 0xFF;
	message[4] = ((tx_message_count & 0x00FF0000) >> 16) & 0xFF;
	message[5] = ((tx_message_count & 0xFF000000) >> 24) & 0xFF;

	// Send the message contents.
	send_time = system_get_time();
	esp_now_send(dest_mac, message, 6);
	os_printf("Tx message for ["MACSTR"] of length 6.\n", MAC2STR(dest_mac));

	// Start the receive timer.
	os_timer_arm(&rx_timer, RESPONSE_TIMEOUT_INTERVAL, 0);
}

While receiving messages takes a bit more effort to validate (and print out appropriate error messages), as well as to send back a reply if we’re the receiver node:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
LOCAL void ICACHE_FLASH_ATTR message_rx_cb(
		uint8_t *mac, uint8_t *data, uint8_t len) {
	// Disable the receive timer.
	os_timer_disarm(&rx_timer);
	
	os_printf("Rx message from ["MACSTR"] of length %d.\n", MAC2STR(mac), len);

	// Check the message contents.
	bool message_ok = false;
	if (len != 6) {
		os_printf("Rx message from ["MACSTR"] is of length %d, 6 expected.\n",
				MAC2STR(mac), len);
	} else if ((data[0] != 0xAA) || (data[1] != 0xBB)) {
		os_printf("Rx message from ["MACSTR"] has a bad header %02x, %02x.\n",
				MAC2STR(mac), data[0], data[1]);
	} else {
		// Extract the counter and compare the value to what we expect.
		uint32_t counter = (data[2] +
		                   (data[3] << 8) +
		                   (data[4] << 16) +
		                   (data[5] << 24));
		uint32_t expected;
		if (mode == SENDER) {
			// Senders expect the counter to be reflected back to it.
			expected = tx_message_count;
		} else {
			// Receivers expect the counter to be incremented by 1 each time.
			expected = last_counter + 1;
		}
		if (counter != expected) {
			os_printf("Rx message from ["MACSTR"] counter mismatch "
					"(%d, expecting %d).\n", MAC2STR(mac), counter, expected);
			if (mode == RECEIVER) {
				last_counter = counter;
			}
		} else {
			// The message is as we expect.
			message_ok = true;
			if (mode == RECEIVER) {
				// Store the counter and MAC for replying in a separate task.
				os_memcpy(last_mac, mac, 6);
				last_counter = counter;
				
				// Post a message to transmit the reply.
				system_os_post(REPLY_PRI, 0, 0);
			} else {
				// Check the timing of the round trip.
				uint32_t now = system_get_time();
				uint32_t diff = now - send_time;
				os_printf("Message %5d RTT - %d us.\n", tx_message_count, diff);
			}

			// Set the LEDs GPIO 12 = good, GPIO 4 = bad.
			gpio_output_set(BIT12, BIT4, BIT4 | BIT12, 0);
		}
	}

	if (!message_ok) {
		// Set the LEDs GPIO 12 = good, GPIO 4 = bad. Set both, as we received 
		// something, but it's not what we're expecting.
		gpio_output_set(BIT4 | BIT12, 0, BIT4 | BIT12, 0);
	}
}

The system_os_post function call uses a task queue to invoke the following function to send a message back to the sender:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
LOCAL void ICACHE_FLASH_ATTR reply_to_message(os_event_t *event) {
	// Relay the message back to the sender.
	uint8_t message[6];
	message[0] = 0xAA;
	message[1] = 0xBB;
	message[2] = ((last_counter & 0x000000FF));
	message[3] = ((last_counter & 0x0000FF00) >> 8)  & 0xFF;
	message[4] = ((last_counter & 0x00FF0000) >> 16) & 0xFF;
	message[5] = ((last_counter & 0xFF000000) >> 24) & 0xFF;
	esp_now_send(last_mac, message, 6);
	os_printf("Tx message for ["MACSTR"] of length 6.\n", MAC2STR(dest_mac));

	// Start the receive timer for the next message.
	os_timer_arm(&rx_timer, RECEIVER_TIMEOUT_INTERVAL, 0);
}

Source

The full source code for the servo demonstration project is available in GitHub here.

comments powered by Disqus