This is the second article in a series about using ESP-Now in Micropython to implement an ESP32-based master-slave network. Part one describes the main network comms class and part three describes how to do reliable OTA file updates.
The 2.4GHz wifi band is getting crowded. Much of the heaviest traffic now goes on 5GHz, but that leaves a lot of devices competing for bandwidth; in particular those in the world of IOT. Modern domestic routers deal with this by channel-hopping; they periodically monitor the network to see which channel is the least busy and switch to that channel. This can happen as frequently as every hour or two, but I would hope that routers are intelligent enough to realise that constantly ping-ponging back and forth would be mostly ineffective as well as annoying. For although computer operating systems can deal pretty seamlessly with channel-hopping, simpler devices don't have the firmware to do this, leaving you and me to do it in our applications.
The simplest and most recommended approach is of course to restrict the router to a single channel, but this is only feasible if you have control of the router. For my master-slave system I made no such assumption; I decided to grab the bull by the horns and work out a solution.
Before continuing, I should mention that this code has been developed for the ESP32-C3, which is a cut-down device with only a single radio subsystem. This means it can only handle a single wifi channel. Other variants have two subsystems and can operate two channels independently. The code here and in the rest of the series is built to handle the more constrained environment. During development I found that many published articles about ESP-Now on the ESP32 don't work on the C3 variant as they rely on having the dual-radio.
In fact, with dual radio devices there may well be no need to channel-hop at all. The entire ESP-Now subsystem can operate independently of the AP and STA interfaces. However, the C3 variant occupies a niche that was previously filled by the ESP8266; low cost and very small size, as exhibited by devices such as the ESP32-C3 Super Mini and the ESP01-C3, allowing a full implementation of Micropython to run many cost and space-critical applications. Here's a ESP32-C3 Super Mini (with antenna) and an ESP01-C3.
So if you're still with me, here's the code:
import asyncio,machine,time
class Channels():
def __init__(self,espComms):
print('Starting Channels')
self.espComms=espComms
self.config=espComms.config
self.channels=[1,6,11]
self.myMaster=self.config.getMyMaster()
if self.config.isMaster():
self.ssid=self.config.getSSID()
self.password=self.config.getPassword()
asyncio.create_task(self.checkRouterChannel())
self.resetCounter()
def setupSlaveTasks(self):
asyncio.create_task(self.findMyMaster())
asyncio.create_task(self.countMissingMessages())
def resetCounter(self):
print('Resetting counter')
self.idleCount=0
async def findMyMaster(self):
if await self.ping(): return
self.hopToNextChannel()
asyncio.get_event_loop().stop()
machine.reset()
async def ping(self):
peer=bytes.fromhex(self.myMaster)
self.espComms.espSend(peer,'ping')
_,msg=self.espComms.e.recv(1000)
print('Ping response from',self.myMaster,':',msg)
if msg:
print('Found master on channel',self.espComms.channel)
return True
return False
async def countMissingMessages(self):
print('Count missing messages')
espComms=self.espComms
ap=espComms.ap
e=espComms.e
self.idleCount=0
while True:
await asyncio.sleep(1)
self.idleCount+=1
limit=30
if self.idleCount>limit:
print('No messages for 30 seconds')
# Retry the current channel
if await self.ping():
self.idleCount=0
continue
self.hopToNextChannel()
channel=self.hopToNextChannel()
asyncio.get_event_loop().stop()
machine.reset()
def hopToNextChannel(self):
index=-1
for n,value in enumerate(self.channels):
if value==self.espComms.channel:
self.espComms.channel=self.channels[(n+1)%len(self.channels)]
index=n
break
if index==-1: self.espComms.channel=self.channels[0]
self.config.setChannel(self.espComms.channel)
async def checkRouterChannel(self):
print('Check the router channel')
while True:
await asyncio.sleep(300)
sta=self.espComms.sta
sta.disconnect()
time.sleep(1)
print('Reconnecting...',end='')
sta.connect(self.ssid,self.password)
while not sta.isconnected():
time.sleep(1)
print('.',end='')
channel=sta.config('channel')
if channel!=self.espComms.channel:
print(' router changed channel from',self.espComms.channel,'to',channel)
asyncio.get_event_loop().stop()
machine.reset()
print(' no channel change')
self.espComms.restartESPNow()
In this system, the ESPComms
class described previously manages all network functions except for the channel hopping, which is done by this Channels
class. The system also makes heavy use of a Config
class, which manages system data such as SSIDs and passwords, I/O pin usage and so on. It also acts as a central routing point for most communications between other modules, having getter and setter functions that just pass on calls to the appropriate class. Channels
is something of an exception as it's tied quite closely to ESPComms
, so most of the function calls in this class go there directly.
__init__()
This defines the channels to be used. Although there are about 14 distinct channels in the 2.4GHz band, they are close enough together that adjacent ones overlap quite seriously. In fact, there are only three completely non-overlapping channels: 1, 6 and 11, so it will come as no surprise that most routers hop between these three. The function defines these channels. Its other job is to start up an asynchronous job (checkRouterChannel()
, see below) to monitor the system and detect when the channel has changed. This is needed when the system acts as the master device, as otherwise the change of channel might go undetected.
setupSlaveTasks()
This is typically called later during initialization, after other things have settled down.
resetCounter()
The class constantly increments a counter, and when it reaches a predefined limit, action must be taken. This function is called from ESPComms
whenever a message is received, to prevent the action from being triggered.
findMyMaster()
When a slave starts up it has no way of knowing which channel its master device is using. So it calls this function, which issues a 'ping' message on the channel that was saved during the last run. If it gets a reply it knows it's picked the right channel. Otherwise, it hops to the next channel, thereby saving the new channel number, and resets itself. Only once it gets a reply to the ping can it break out of this endless cycle and start normal operations.
countMissingMessages()
This function runs continuously, incrementing the counter and checking if it has exceeded its set limit. If so, it first pings the master to see if this is still awake and on the same channel. There may be a perfectly valid reason for missing messages, such as the system being in maintenance mode where normal operations are suspended. If the ping fails, the device will reboot and renter the findMyMaster()
cycle above.
ping()
This sends a 'ping' message to the master device and waits for a reply, returning True
if it gets one within a second. To save you having to look it up, the function espSend()
in ESPComms
is:
def espSend(self,peer,msg):
if self.addPeer(peer):
try: self.e.send(peer,msg)
except Exception as ex: print('espSend:',ex)
countMissingMessages()
This continually increments idleCount
and checks if it has reached its predefined limit. If so, messages have gone missing, so it hops to the next channel and resets.
checkRouterChannel()
This is called periodically - say every 5 minutes - by the system master to ensure it is still on the same channel as the router. To do this, the STA interface must be deactivated and reconnected. If the channel has changed, a reboot is needed, otherwise ESP-Now can be restarted.
Top comments (0)