RF24 is not reading messages that were acknowledged successfully

The problem

The receiving side seems to be not receiving some incoming messages that were reported as verified by auto ack on the sending side.

Do you have any suggestions as to how this situation might be possible at all, considering the logic I have implemented, and how to fix the issue?

Hardware setup

I have Arduino Nano as a sender and Teensy LC as a receiver.
Both RF24 boards (very likely, fakes) have 10uF capacitors soldered directly onto their GND and VCC pins.

Nano is powered from 2 AA batteries using two boost converters for 4.6 (for Nano) and 3.3 volts (for RF24). I had to turn the voltage down from 5V to 4.6V because at 5V it lost much more packets; maybe because of 3V3 -> 5V signal differences (I guess, I'll have to try also a level converter, even if rf24 is told to tolerate 5V input signals well). In the final design, I intend to use Arduino Pro Mini 3V version to avoid signal level inconsistencies.

Teensy LC is powered by USB, and its RF24 is powered from Teensy's 3V3 pin (should be enough, since that provides up to 100mA, and RF24 shouldn't consume more than that, judging by specs).

Programming

I'm using TMRh20 RF24 library, version 1.4.1. I did the first tests using the basic test examples I found mentioned here on the Arduino forums. Everything worked just fine, and I proceeded with more in-depth stress-testing and adjustments for my project. I haven't yet connected any other sensors, first I'm just trying to get RF24 to work reliably.

The general approach is as follows.

For my project, if a packet failed to be delivered soon enough, I might have some new data from sensors to deliver instead. So, it is better to give up on the old message and just try to send a new one instead. So, for the sending side, I set retries to pretty low-ish values of (2, 2). If the last attempt to write() a message failed and there is no new data yet, then I'll be sending the same old message the next time. The receiving side will detect the duplicates and discard them.

I'm sending dummy generated random numbers as my data packets, and also adding last ACK time (ms) in a byte for diagnostics.

Data rate is RF24_1MBPS and the power is RF24_PA_MIN (because both devices are in the same room).

Every message has the last byte with sequence number looping from 0 to 255. The receiver checks if the difference between two seq numbers has jumped by more than 1, in which case it logs the previous and the current packet to the serial output. I'll know I'll miss the edge case for lost packets in the moments when seqnum wraps around from 255 to 0, but this condition won't prevent me from detecting most of the lost packets in any case.

So, from the algorithm perspective, it should work rock-solid - RF24 auto-ack mechanism has me covered to ensure the receiver has received the message (if the sender has received an ACK back, it should guarantee the receiver side has actually received the message, right?), and if auto-ack returns a failure, then I'll keep sending the old message myself until I get a success result from write().

But it doesn't work that way. The receiving side still reports some lost messages even in the cases when the sender reports it has write() success!! How is that even possible?

My last test showed 11 lost out of 8000. Still, I expected none to be lost at all. How can the receiver side not see a message that it has sent an ACK for??

Is radio.write sometimes lying to me and reporting true result even when ACK not received?
Or is radio.available or radio.read available sometimes lying to me and not returning the data that was received and ack-ed by RF24?

What am I missing here?

I captured one such case from the serial logs. Pay attention to the last byte, that's a seqnum; the other bytes are random data. The 7th byte is the duration of the previous write() just for stats.

The sender:

Sending: 16 23 39 3 89 165 1 1
Sending: 220 238 174 145 95 51 0 2
Sending: 248 188 58 210 103 231 0 3
Sending: 16 144 57 31 113 245 0 4
Sending: 123 78 26 27 131 64 0 5
Tx failed
Sending: 123 78 26 27 131 64 0 5
Sending: 255 77 236 73 35 57 1 6

The receiver:

Packet loss detected!
Previous payload: 248 188 58 210 103 231 0 3
Current payload: 123 78 26 27 131 64 0 5

So, you can see that the receiver has failed to read the packet with number 4, which did not report any failures on the sender side!

However, it has correctly received the next message that has failed on the sender and was manually resent in the next loop() iteration. So, at least the part with sending duplicates is working reliably.

Below I provide the relevant code fragments from my sender / receiver (I'll shorten the code for brevity, it has some unrelated debugging code for LCD and serial and stats counting).

The sender:

#include <SPI.h>
#include <nRF24L01.h>
#include <RF24.h>
#include <printf.h>

#define RADIO_CE_PIN 9
#define RADIO_CS_PIN 10

const byte PIPE_NAME[5] = {'a','a','a','a','1'};

RF24 radio(RADIO_CE_PIN, RADIO_CS_PIN);

// wrapping-around short sequence number 
// to detect missed packets on the receiver side
byte seqNum = 0;
byte lastAckTimeMs = 0;
bool lastSendResult = true;

// 6 bytes of some random data, 1 byte of last ACK time in ms (expected less than 255) and 1 byte with seqnum
byte payload[8];

// just for printing stuff
char buf[100];

void setup() {

  Serial.begin(9600);
  while (!Serial) {
    // some boards (Teensy) need to wait to ensure access to serial over USB
    // but it will freeze setup() until serial is opened from the other (e.g. Arduino IDE) side
    // so, do not use for production
  }

  printf_begin(); // needed for radio.printDetails to prevent crashing on low-memory devices
  
  initTransmittingRadio();
}

void loop() {
  send();
  //delay(1000);
}

// ===============================

void initTransmittingRadio() {
  
  if (!radio.begin()) {
    Serial.println(F("Radio hardware is not responding!"));
    while (1) {} // hold in infinite loop
  }

  radio.setDataRate(RF24_1MBPS);  //  RF24_1MBPS, RF24_2MBPS, RF24_250KBPS

  radio.setRetries(2, 2); // delay, count
  // 0 means 250us, 15 means 4000us
  // small retry count - don't wait too long, might have new data to transmit

  // Set the PA Level low because nodes will be close to each other
  radio.setPALevel(RF24_PA_MIN);  // RF24_PA_MAX is default; RF24_PA_MIN, RF24_PA_LOW

  // move to a higher channel to avoid clashes with WiFi, BT etc.
  // The range is 2.400 to 2.525 Ghz
  // The nRF24L01 channel spacing is 1 Mhz which gives 125 possible channels numbered 0 .. 124.
  radio.setChannel(115);

  // save on transmission time by setting the radio to only transmit the number of bytes we need.
  // max packet is 32 bytes at a time
  radio.setPayloadSize(sizeof(payload));
  // setPayloadSize in older library versions must come before opening pipes to be effective!
  
  radio.openWritingPipe(PIPE_NAME);

  // switch to transmit mode
  radio.stopListening();

  Serial.println(F("Radio hardware initialized"));
  radio.printPrettyDetails();

  randomSeed(analogRead(0));
}

void send() {

  // if radio.write failed (missing ack?), keep the old payload;
  // a radio.write failure doesn't necessarily mean the receiver did not get the message,
  // in which case we'll cause duplicates for the receiver to filter out
  if (lastSendResult) {
    prepareNextPayload();
  }
  
  sprintf(buf, "Sending: %u %u %u %u %u %u %u %u", payload[0], payload[1],
    payload[2], payload[3], payload[4], payload[5], payload[6], payload[7]);
  Serial.println(buf);
            
  unsigned long startTime = millis();
  lastSendResult = radio.write(&payload, sizeof(payload));
  // write will block if setRetries enabled

  unsigned long currentTime = millis();
  lastAckTimeMs = currentTime - startTime;

  if (!lastSendResult) {
    Serial.println(F("Tx failed"));
  }
}

void prepareNextPayload() {

    // random bytes, seqnum last
    for (byte i = 0; i<6;i++) {
      payload[i] = random(256);
    }
    payload[6] = lastAckTimeMs;
    payload[7] = seqNum;

    // increase the counter for the next time; will wrap around 255
    seqNum++;
    //seqNum++; // to trigger fake loss detection on the receiver
}

The receiver:

#include <SPI.h>
#include <nRF24L01.h>
#include <RF24.h>
#include <printf.h>

#define RADIO_CE_PIN 9
#define RADIO_CS_PIN 10

RF24 radio(RADIO_CE_PIN, RADIO_CS_PIN);

const byte PIPE_NAME[5] = {'a','a','a','a','1'};

// stats
unsigned long packetsReceived = 0;
unsigned long packetsLost = 0;
unsigned long duplicatesDetected = 0;

bool wasRetransmitted = false;
byte lastAckTimeMs = 0;

// just for printing stuff
char buf[100];

byte payload[8];
byte previousPayload[8];

byte lastSeqNum = 0;
// to avoid lost packet false positives on start
bool freshRun = true;

// to print stats for batches, not every message
const unsigned int LOG_EVERY_N = 100;
unsigned int collectedSinceLast = 0;


void setup() {

  Serial.begin(9600);
  while (!Serial) {
    // some boards (Teensy) need to wait to ensure access to serial over USB
    // but it will freeze setup() until serial is opened from the other (e.g. Arduino IDE) side
    // so, do not use for production
  }

  printf_begin(); // needed for radio.printDetails to prevent crashing on low-memory devices
  
  initReceivingRadio();
}

void loop() {
  getData();
  serialPrintData();
}


// ===============================

void initReceivingRadio() {

  if (!radio.begin()) {

#ifdef WITH_SERIAL_DBG
    Serial.println(F("Radio hardware is not responding!"));
#endif

    while (1) {} // hold in infinite loop
  }
   
  radio.setDataRate(RF24_1MBPS); //  RF24_1MBPS, RF24_2MBPS, RF24_250KBPS

  // default - true
  radio.setAutoAck(true);

  // Set the PA Level low because nodes will be close to each other;
  // receiver side transmits ack messages
  radio.setPALevel(RF24_PA_MIN);  // RF24_PA_MAX is default; RF24_PA_MIN, RF24_PA_LOW

  // move to a higher channel to avoid clashes with WiFi, BT etc.
  // The range is 2.400 to 2.525 Ghz
  // The nRF24L01 channel spacing is 1 Mhz which gives 125 possible channels numbered 0 .. 124.
  radio.setChannel(115);
  
  radio.setPayloadSize(sizeof(payload));
  // setPayloadSize in older library versions must come before opening pipes to be effective!
  
  radio.openReadingPipe(1, PIPE_NAME);

  // switch to receive mode
  radio.startListening();

  Serial.println(F("Radio hardware initialized"));
  radio.printPrettyDetails();
}

void getData() {

  // reset duplicate counter
  wasRetransmitted = false;
    
  // check if have something unread yet in the receiving pipe buffer
  if (radio.available()) {
    // keep old payload for comparison when losing packets
    memcpy(&previousPayload, &payload, sizeof(payload));
    radio.read(&payload, sizeof(payload));

    collectStats();
  }
}

void collectStats() {
  
    packetsReceived++;
    collectedSinceLast++;

    byte currSeqNum = payload[7];
    lastAckTimeMs = payload[6];
    
    if (!freshRun) {
      int seqDiff = currSeqNum - lastSeqNum;

      // if last received seq num differs by more than 1
      // then consider that we had missed some packets in between
      // (or they arrived in wrong order? shouldn't happen, we don't send new until ACK)
      // of course, we miss the edge case for 255 -> 0 wrap-around, but not a big deal
      if (seqDiff > 1) {

            Serial.println(F("Packet loss detected!"));
            sprintf(buf, "Previous payload: %u %u %u %u %u %u %u %u", previousPayload[0], previousPayload[1],
              previousPayload[2], previousPayload[3], previousPayload[4], previousPayload[5], previousPayload[6], previousPayload[7]);
            Serial.println(buf);
            
            sprintf(buf, "Current payload: %u %u %u %u %u %u %u %u", payload[0], payload[1], payload[2], 
              payload[3], payload[4], payload[5], payload[6], payload[7]);
            Serial.println(buf);
        
        packetsLost++;
      } else {
        if (seqDiff == 0) {
          wasRetransmitted = true;
          duplicatesDetected++;
        }
      }
    }
    
    lastSeqNum = currSeqNum;
    freshRun = false;
}

void serialPrintData() {
  if (wasRetransmitted) {
    Serial.println(F("Detected a possible retransmitted message with repeating sequence number"));
  }
    
  if (collectedSinceLast == LOG_EVERY_N) {
    sprintf(buf, "Received: %lu; lost: %lu; duplicates: %lu; last ACK time: %u",
        packetsReceived, packetsLost, duplicatesDetected, lastAckTimeMs);
    Serial.println(buf);
    collectedSinceLast = 0;
  }
}

Ok, I have one suspect.

It might be caused by the fact that the receiver (Teensy) is doing lots of stuff in its loop() (which I did not include in the code examples). For example, printing to the LCD takes up to 50ms.

Maybe then RF24 buffer starts removing old messages before I read them. I (wrongly?) assumed that RF24 sends the ACK only after I have read the message, but it might be not so. That's why it might happen that RF24 sends an ACK, and then its buffer is full and it throws the message out to store a new one; and when I call read() it's too late.

One solution is to implement my own ACK... which I did - it wasn't that difficult at all :slight_smile:

In addition, I found a great library AceRoutine which was the only multithreading library working on Teensy LC (tried Zilch and TeensyThreads - no luck).

Now I have custom ACK in under 1ms timeout and also output to an LCD that doesn't cause any ACK delays. In theory, I could try to return to the RF24 built-in ACK to see if removing that delay helped to resolve missing acknowledged packets, but still, my own ACK seems to be more reliable anyway because it ACKs only when my program has seen the message.

Here's my ACK code, if anybody finds it useful. Sender side:


  radio.setRetries(0, 0); // delay, count
  // disable retry - will wait manually for ACKs
  // although should not care because sending as multicast
  radio.setAutoAck(false);

  // write to main, read on ACK
  radio.openWritingPipe(MAIN_PIPE_NAME);
  radio.openReadingPipe(1, ACK_PIPE_NAME);

...

void waitAck() {

  unsigned long startTime = millis();

  radio.startListening();

  // assume success
  lastSendSuccess = true;

  byte payloadXorSum = xorChecksum(payload, sizeof(payload));
  byte sentPacketNum = payload[7];

  // wait for the ACK reply just a bit - as long as can afford
  while (!radio.available()) {

    if (millis() - startTime >= WAIT_FOR_ACK_MS) {
      lastSendSuccess = false;

      sprintf(buf, "Timed out while waiting for an ACK: %lu", millis() - startTime);
      Serial.println(buf);

      break;
      // give up
    }
  }

  // did we get anything before timeout?
  if (lastSendSuccess) {

    // now assume failed until we find the right ACK
    lastSendSuccess = false;

    // we might have more than one ACK - in case if some old ACKs arrived too late
    // so we read all the packets and analyze them to find the right ACK
    while (radio.available()) {
      radio.read(&ackPayload, sizeof(ackPayload));

      // ack packet has seqnum and xor checksum to ensure unique-ish packet ids
      // the remaining 6 bytes are garbage

      if (ackPayload[0] == sentPacketNum && ackPayload[1] == payloadXorSum) {
        lastSendSuccess = true;

        sprintf(buf, "ACK found in: %lu", millis() - startTime);
        Serial.println(buf);

      } else {

        // seems never happening - most likely, rf24 flushes out the received data when switching modes
       Serial.println(F("Received unexpected data for an ACK"));

       sprintf(buf, "Wrong ACK: %u %u %u %u %u %u %u %u", ackPayload[0], ackPayload[1],
        ackPayload[2], ackPayload[3], ackPayload[4], ackPayload[5], ackPayload[6], ackPayload[7]);
       Serial.println(buf);
      }
    }

    if (!lastSendSuccess) {
      Serial.println(F("No valid ACK received"));
    }
  }

  radio.stopListening();
}

Receiver side:

  radio.setRetries(0, 0); // delay, count
  // disable retry - will wait manually for ACKs
  // although should not care because sending as multicast
  radio.setAutoAck(false);

  // write to ACK, read on main
  radio.openWritingPipe(ACK_PIPE_NAME);
  radio.openReadingPipe(1, MAIN_PIPE_NAME);

...

void sendAck() {
  byte payloadXorSum = xorChecksum(payload, sizeof(payload));
  byte sentPacketNum = payload[7];

  ackPayload[0] = sentPacketNum;
  ackPayload[1] = payloadXorSum;

  radio.stopListening();

  radio.write(&ackPayload, sizeof(ackPayload), true); // true - to not wait for ACK

  radio.startListening();
}

byte xorChecksum(const byte* array, int size) {

  byte cs = 0;
  for (int i = 0; i < size; i++) {
    cs ^= array[i];
  }

  return cs;
}

I'll do a more intense stress test at full speed tomorrow to see if I still get any packet loss, and also how many duplicates there will be because of missed ACKs.

Sending an Ack, but not signaling the reception, is the mechanism to suppress duplicate packets.

I have only seen that behavior when justified,
when two consecutive received packets have the same id and the same CRC.
This can lead to lost packets when polling multiple clients with the same packet.

Your packets are pretty random and you have only one target address.

Did you try to exchange the receiver with different hardware?

Why do you use only 2 retries?
IIRC the failing time for 15 retries was 45 us with 2 MBits, that's quite fast.
Why do you have different settings for delay and timeout in rx and tx sketches?

I tried also using up to 15, but this did not change the behavior. The problem is related only to the cases when ACK is being received during those 2 periods. ACKs themselves arrive correctly, it's just my receiving code does not get the message that was ACKed by RF24.

I don't have any setRetries call in my initial rx code (which, most likely, makes them default 15,15) because I thought it's irrelevant for rx. In my understanding, rx code does not call write(), so there is no use for wait&retry mechanism.
But I might be wrong and RF24 might be somehow using these values even for rx. Not sure.

I might try that. Still, seeing how messages are getting lost even after getting ACKed, I was not sure I can trust the built-in ACK at all. I mean - if a message was ACKed and the sender received the ACK, then no matter what the hardware problem is - the message was delivered successfully and it should be extractable from the rx buffers. That's why I started suspecting there might be something wrong on my rx side, and so I found that my LCD display code is causing up to 50ms delays. It means that during my stress test the rx RF24 is receiving (and properly ack-ing) messages much faster than I read(() them out of the buffer!
So, I highly suspect this might cause the messages to disappear. But it depends on - what happens when the rx RF24 receives a new message when its buffer is already full.
Does it throw away the new message? Then my tx side should see it fail and resend it again, unless rf24 doesn't send an ack for the message it can't fit into receiving buffer (which would be a foolish thing to do, but we never know how those fake rf24 chips behave).
Or does it throw away the old message from the buffer to have space for the new one? In this case it might cause the behavior I see - the old message was ack-ed, but I never read it and it was dropped in favor of the new one. I'll try to escalate the issue by adding a long delay in rx loop to better see what happens.

Anyway, if it turns out that rf24 (or its fakes) throws out acked messages that rx code hasn't read yet for any reason (delaying too much etc.), then a custom ack-ing mechanism seems to be the only solution.

BTW, AceRoutine did not help much with the LCD delay - AceRoutine is not parallel processing but just concurrent; it can help to split my loop into tasks with different delays (to reduce number of refreshes without manual time management), but it doesn't execute on another thread. So, loop() will get inevitably delayed sometime. I could add another dedicated Nano to control the rf24 exclusively - it would have its own loop and would be able to consume the messages fast enough to avoid buffer overflow.

One way of causing this is to disable auto-acknowlegement:

If auto-ack is disabled, then any attempt to transmit will also return true (even if the payload was not received).

from: Optimized high speed nRF24L01+ driver class documentation: RF24 Class Reference

Bad connections and a poor power supply can also cause such mysterious behaviour.

As I stated, the dropping of packets should not happen under normal circumstances.

Acks are just normal packets without data (and corresponding write), so your thinking is wrong.

I have not seen this behavior caused by bad connections or bad power supply.

IIRC it will drop the oldest of the three buffered packets by overwriting with the new one.

The NRF can use more power in peaks, so at least a capacitor on the NRF power pins could help.

No need for a level converter, the NRF works well with 5V systems natively.

I've just got a pair on my desk under test now. Even if I completely remove the transmitter NRF24L01 from its socket, the system continually reports that it has received an acknowledgement from the receiver. I've just tried it. I guess that some connection/power problems could exhibit the same effect.

Yeah, if the error would show continually, I would also suspect that.

Hmm, I'm now trying to think how setRetries might be of any use for rx side.

As far as I know, the parameters in setRetries() are used to control ARD (auto retransmit delay) when waiting for an ACK, not sending it.

Otherwise, if we think that rx side is also using ARD when sending an ACK back to the tx, it would be strange because it would mean that rx sends an ACK and then waits for an ACK of an ACK from the tx side... that's getting recursive.

Nordic specs https://www.mouser.com/datasheet/2/297/nRF24L01_Product_Specification_v2_0-9199.pdf#page=31&zoom=100,84,177 chapter "7.6.2 PRX operation" also seems to confirm that ARD wait/retry is not used by the rx side - it just spits out the ACK in "TX mode Transmit ACK" and does not wait for ack of an ack response back from tx, hence setRetries seems to have no any effect on the rx side. In contrast, "7.6.1 PTX operation" is using ARD in those "Timeout?" and "Number of retries = ARC?" checks.

In my case, auto-ack was initially enabled and working properly.

This would very well explain the observed behavior. However, I just found that nRF24L01+ spec pdfs say otherwise:

If the RX FIFOs are full, the received packet is discarded.

So, it seems, the newest ones would get discarded and I would lose them.
However, Nordic specs chapter "7.6.2 PRX operation" seems to imply that auto-ack kicks in only after the packet has been stored in RX FIFO. The check "RX FIFO Full?" -> Yes ignores the packet and returns back to waiting without sending an ack.
So, if rx side loses packets because RX FIFO was full (because my code does not read it fast enough), then tx side should not receive acks for those messages (and indeed, I see some messages getting lost in somewhat regular time intervals, and I just resend them). And again, this does not explain where do those packets disappear that have been ack-ed and have enough random data to not be confused as duplicates by RF24. Still a mystery :smiley:

At least, one lesson was learned - if your loop() on receiver is too slow, you will lose some of the received packets. And maybe you even will lose the ones that were ack-ed (but the underlying reason is still not confirmed yet - maybe it indeed is something hardware-related.

Oh, one thing that might be the key.

I found a quote by Robin2 in some other topic:

This would nicely explain the behavior I see! In this case, here's what happens:

  • tx sends a packet

  • rx rf24 receives it and puts in FIFO and sends an ack back

  • my code does not yet read it - doing something heavy in loop()

  • tx sends a packet

  • rx rf24 receives it and puts in FIFO again (now it has just one slot) and sends an ack back

  • my code wakes up and reads the first message ... after this, the library clears the FIFO out and loses the second message? Does this really happen?

  • tx sends a packet

  • rx rf24 receives it and puts in FIFO again (now it has just one slot) and sends an ack back

  • my code wakes up and reads the third message ... but I did not read the second one because the library cleaned it away. So, I detect a lost message because my seq numbers skipped one.

You could speed up the loop(). At 9600 baud, the printing is quite slow and you could probably use 115200 baud.

For stress test, I disable the serial output. But as I mentioned earlier, the loop is slow because of the LCD, and there's not much I can do about it. I'll have to separate the LCD out to another microcontroller with its own loop.

I never used this: AsyncLiquidCrystal - Arduino Libraries
but it appears to be non-blocking and queues commands until the system is ready to execute them. It must have a component which runs every loop cycle, to handle pending commands, instead of simply blocking.

1 Like

Thanks, 6v6gt, this might be helpful. Currently, I have SD1306 which seems to be unsupported by AsyncLiquidCrystal, but I'm considering other display options, that will work with AsyncLiquidCrystal (I have one Hitachi-compatible text display somewhere in my parts box).

Speaking about hardware issues. I noticed that my Nano (tx) processes RF24 communication much more reliably (less failed writes) when powered from USB than when powered from AA batteries + booster to 4.6 volts. So I measured the voltages while powering from USB, and was shocked to see that +5V pin on Nano has only 3.9 volts. I've heard it should have less than 5V because of some circuitry, but I did not expect it to fall that low.

Still, out of curiosity, I lowered my battery voltage for Nano to 3.9 volts (for RFC24 it already was at 3.3v) ... and surprise! now my battery-powered system behaves much better! It doesn't fix the lost messages because of the slow rx loop(), but still otherwise it's all rock solid.

Which makes me wonder if my Nano is some kind of a 3V Nano clone :smiley:

Anyway, at one point I intend to move to Mini Pro 3V version.

An ESP32 can communicate via NRF24L01+ too,
and it has a lot more power than the poor Nano.

That is absolutely uncommon (I measure 4.5 V here),
and you should find out why that seems to be the case.

Good idea, I might also try one of these. But an ESP32 might be an overkill for my current needs - I want to stay with reasonably minimal power consumption.

I'll try my other Nano in the evening, it comes from a different seller.
What's more of a surprise is that this Nano+RF24 works rock solid with that weird voltage and becomes noticeably less table if I feed it with proper 5V. I highly suspect it's caused by poor design choices of some clone manufacturers, or maybe they deliberately created some kind of a 3V frankenNano.

If you pick the right board, switch off Wi-Fi and Bluetooth, the ESP32 doesn't use a lot of power.
It has an extra ultra low power core that works while the other two processor cores deep sleep.

So, I did a quick measurements with my two Nanos. Without any loads and no wires connected, both show about 4.05V on their 5V pin when connected to USB.

They are slightly different, some markings and passive elements look different, and also LED colors are different - one has a red LED for power and the other one has green.

Both have AMS1117 5.0 regulator.

The most peculiar difference is that one works reliably with RF24 only when I supply about 4V instead of 5V, but the other one works reliably also with proper 5V.

I guess, they both are fakes :smiley:

Anyway, this does not affect the lost messages issue. Now I've been running one for hours counting failures and responding with my custom ACK algorithm instead of the default one. Almost a million messages received, no losses detected yet, but a few hundred duplicates (because of my LCD taking too long to draw). But I don't care about the duplicates.

Most likely, I'll move the LCD to another dedicated microcontroller to avoid the response delays (because my project needs real-time-ish reaction and 30ms delay for an ACK would be undesirable.

Anyway, a custom ACK mechanism seems the best solution in cases when you can't be sure you can read the incoming messages fast enough and something (the library?) might delete them from the RX FIFO before you read them.