How to send data from Client to UART BLE Server?

What I want is 2 Seeed Xiao ESP32-C3 chips that can sequentially, each send a byte of data to the other via BLE.

So for example, first Chip A sends "0" to Chip B, then Chip B sends "1" to Chip A, then Chip A sends "2" to Chip B, etc...

I am very new to Bluetooth and don't have a lot of programming experience overall. So far I've only managed to get Chip A to send a byte to Chip B about once a second, but I don't know how to make Chip B send a byte back.

Here is my setup:
Chip A (as mentioned above is a Seeed Xiao ESP32-C3) has a green LED attached to pin 2, and red LED attached to pin 5.
IMG_2900
It functions as a UART BLE Server using the code pasted below. The server is insight-fully (lol) named "UART Hello World 2". It creates a service that has 2 characteristics, one is RX and the other is TX.

When a Client connects to the Server, the green LED turns on. The value of TX starts at zero, and then gets incremented by 1 every second, and the Client receives the new value of TX.

The red LED will light up if the Server's RX characteristic receives a value of "A." I've tested this using a phone running nRF Connect and it works. (I run the Server, connect a phone to it using nRF Connect, and then send "A" to the Server and indeed make the red LED light up.)

My question is, how can I send the value of "A" to the Server using a second Seeed Xiao ESP32-C3 chip as the Client?

This brings us to Chip B (also a Seeed Xiao Esp32-C3). There is nothing connected to any pins so no diagram is included. I have code that makes Chip B become a Client, connect to Chip A, and receive the value of TX as it increments. (Client code is pasted below the UART Server code.) But I don't know what code I need to add in order for Chip B to send a byte back to Chip A. Can anyone point me to some example code I can use to make this happen?

Note: The code comes from the BLE examples provided by Seeed. They seem a little like spaghetti to me but that's not a criticism, just a reflection of my inexperience. Is there already code in the Client that can make it happen?

UART Server Code:

/*
    Video: https://www.youtube.com/watch?v=oCMOYS71NIU
    Based on Neil Kolban example for IDF: https://github.com/nkolban/esp32-snippets/blob/master/cpp_utils/tests/BLE%20Tests/SampleNotify.cpp
    Ported to Arduino ESP32 by Evandro Copercini

   Create a BLE server that, once we receive a connection, will send periodic notifications.
   The service advertises itself as: 6E400001-B5A3-F393-E0A9-E50E24DCCA9E
   Has a characteristic of: 6E400002-B5A3-F393-E0A9-E50E24DCCA9E - used for receiving data with "WRITE" 
   Has a characteristic of: 6E400003-B5A3-F393-E0A9-E50E24DCCA9E - used to send data with  "NOTIFY"

   The design of creating the BLE server is:
   1. Create a BLE Server
   2. Create a BLE Service
   3. Create a BLE Characteristic on the Service
   4. Create a BLE Descriptor on the characteristic
   5. Start the service.
   6. Start advertising.

   In this example rxValue is the data received (only accessible inside that function).
   And txValue is the data to be sent, in this example just a byte incremented every second. 
*/
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>

BLEServer *pServer = NULL;
BLECharacteristic * pTxCharacteristic;
bool deviceConnected = false;
bool oldDeviceConnected = false;
uint8_t txValue = 0;
//int txValue = 0;

int incomingByte = 0;

// See the following for generating UUIDs:
// https://www.uuidgenerator.net/

#define SERVICE_UUID           "6E400001-B5A3-F393-E0A9-E50E24DCCA9E" // UART service UUID
#define CHARACTERISTIC_UUID_RX "6E400002-B5A3-F393-E0A9-E50E24DCCA9E"
#define CHARACTERISTIC_UUID_TX "6E400003-B5A3-F393-E0A9-E50E24DCCA9E"

uint8_t greenLED = 2;
uint8_t redLED = 5;
//bool greenLight = 0;
//bool redLight = 0;

String readValue;

class MyServerCallbacks: public BLEServerCallbacks {
    void onConnect(BLEServer* pServer) {
      deviceConnected = true;
      Serial.println("Client has connected");
      digitalWrite(greenLED, HIGH);
    };

    void onDisconnect(BLEServer* pServer) {
      deviceConnected = false;
      Serial.println("Client has disconnected");
      digitalWrite(greenLED, LOW);
    }
};

class MyCallbacks: public BLECharacteristicCallbacks {
    void onWrite(BLECharacteristic *pCharacteristic) {
      String rxValue = pCharacteristic->getValue();

      if (rxValue.length() > 0) {
        Serial.println("*********");
        Serial.print("Received Value: ");
        for (int i = 0; i < rxValue.length(); i++)
          Serial.print(rxValue[i]);

        Serial.println();
        Serial.println("*********");
        //readValue = rxValue;
        if(rxValue[0] == 'A'){
          digitalWrite(redLED, HIGH);
        }
        else{
          digitalWrite(redLED, LOW);
        }
      }
    }
};


void setup() {
  Serial.begin(115200);

  // Create the BLE Device
  BLEDevice::init("UART Hello World 2");

  // Create the BLE Server
  pServer = BLEDevice::createServer();
  pServer->setCallbacks(new MyServerCallbacks());

  // Create the BLE Service
  BLEService *pService = pServer->createService(SERVICE_UUID);

  // Create a BLE Characteristic
  pTxCharacteristic = pService->createCharacteristic(
                    CHARACTERISTIC_UUID_TX,
                    BLECharacteristic::PROPERTY_NOTIFY
                  );

  pTxCharacteristic->addDescriptor(new BLE2902());

  BLECharacteristic * pRxCharacteristic = pService->createCharacteristic(
                      CHARACTERISTIC_UUID_RX,
                      BLECharacteristic::PROPERTY_WRITE
                    );

  pRxCharacteristic->setCallbacks(new MyCallbacks());

  // Start the service
  pService->start();

  // Start advertising
  /*
  pServer->getAdvertising()->start();
  Serial.println("Waiting on a client connection to notify...");
  */

  // Code below added to get the service advertising
  

  BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
  pAdvertising->addServiceUUID(SERVICE_UUID);
  pAdvertising->setScanResponse(true);
  pAdvertising->setMinPreferred(0x06);  // functions that help with iPhone connections issue
  pAdvertising->setMinPreferred(0x12);
  BLEDevice::startAdvertising();
  

  pinMode(greenLED, OUTPUT);
  pinMode(redLED, OUTPUT);
}

void loop() {

  if (deviceConnected) {
    //if(Serial.available())
    //delay(2);
    //{
      //incomingByte = Serial.read();
      //if(incomingByte == 65)
      //{
        Serial.print("Value: ");
        Serial.println(txValue);
        pTxCharacteristic->setValue(&txValue, 1);
        pTxCharacteristic->notify();
        txValue++;
        delay(1000); // bluetooth stack will go into congestion, if too many packets are sent
      //}
    //}
  }

  // disconnecting
  if (!deviceConnected && oldDeviceConnected) {
    delay(500); // give the bluetooth stack the chance to get things ready
    pServer->startAdvertising(); // restart advertising
    Serial.println("start advertising");
    oldDeviceConnected = deviceConnected;
  }
  // connecting
  if (deviceConnected && !oldDeviceConnected) {
    // do stuff here on connecting
    oldDeviceConnected = deviceConnected;
  }

}

Client code:

/**
 * A BLE client example that is rich in capabilities.
 * There is a lot new capabilities implemented.
 * author unknown
 * updated by chegewara
 */

#include "BLEDevice.h"
//#include "BLEScan.h"

// The remote service we wish to connect to.
static BLEUUID serviceUUID("6E400001-B5A3-F393-E0A9-E50E24DCCA9E");
// The characteristic of the remote service we are interested in.
static BLEUUID charUUID("6E400003-B5A3-F393-E0A9-E50E24DCCA9E");

static boolean doConnect = false;
static boolean connected = false;
static boolean doScan = false;
static BLERemoteCharacteristic *pRemoteCharacteristic;
static BLEAdvertisedDevice *myDevice;

static void notifyCallback(BLERemoteCharacteristic *pBLERemoteCharacteristic, uint8_t *pData, size_t length, bool isNotify) {
  Serial.print("Notify callback for characteristic ");
  Serial.print(pBLERemoteCharacteristic->getUUID().toString().c_str());
  Serial.print(" of data length ");
  Serial.println(length);
  Serial.print("data: ");
  Serial.write(pData, length);
  Serial.println();
}

class MyClientCallback : public BLEClientCallbacks {
  void onConnect(BLEClient *pclient) {}

  void onDisconnect(BLEClient *pclient) {
    connected = false;
    Serial.println("onDisconnect");
  }
};

bool connectToServer() {
  Serial.print("Forming a connection to ");
  Serial.println(myDevice->getAddress().toString().c_str());

  BLEClient *pClient = BLEDevice::createClient();
  Serial.println(" - Created client");

  pClient->setClientCallbacks(new MyClientCallback());

  // Connect to the remove BLE Server.
  pClient->connect(myDevice);  // if you pass BLEAdvertisedDevice instead of address, it will be recognized type of peer device address (public or private)
  Serial.println(" - Connected to server");
  pClient->setMTU(517);  //set client to request maximum MTU from server (default is 23 otherwise)

  // Obtain a reference to the service we are after in the remote BLE server.
  BLERemoteService *pRemoteService = pClient->getService(serviceUUID);
  if (pRemoteService == nullptr) {
    Serial.print("Failed to find our service UUID: ");
    Serial.println(serviceUUID.toString().c_str());
    pClient->disconnect();
    return false;
  }
  Serial.println(" - Found our service");

  // Obtain a reference to the characteristic in the service of the remote BLE server.
  pRemoteCharacteristic = pRemoteService->getCharacteristic(charUUID);
  if (pRemoteCharacteristic == nullptr) {
    Serial.print("Failed to find our characteristic UUID: ");
    Serial.println(charUUID.toString().c_str());
    pClient->disconnect();
    return false;
  }
  Serial.println(" - Found our characteristic");

  // Read the value of the characteristic.
  if (pRemoteCharacteristic->canRead()) {
    String value = pRemoteCharacteristic->readValue();
    Serial.print("The characteristic value was: ");
    Serial.println(value.c_str());
  }

  if (pRemoteCharacteristic->canNotify()) {
    pRemoteCharacteristic->registerForNotify(notifyCallback);
  }

  connected = true;
  return true;
}
/**
 * Scan for BLE servers and find the first one that advertises the service we are looking for.
 */
class MyAdvertisedDeviceCallbacks : public BLEAdvertisedDeviceCallbacks {
  /**
   * Called for each advertising BLE server.
   */
  void onResult(BLEAdvertisedDevice advertisedDevice) {
    Serial.print("BLE Advertised Device found: ");
    Serial.println(advertisedDevice.toString().c_str());

    // We have found a device, let us now see if it contains the service we are looking for.
    if (advertisedDevice.haveServiceUUID() && advertisedDevice.isAdvertisingService(serviceUUID)) {

      BLEDevice::getScan()->stop();
      myDevice = new BLEAdvertisedDevice(advertisedDevice);
      doConnect = true;
      doScan = true;

    }  // Found our server
  }  // onResult
};  // MyAdvertisedDeviceCallbacks

void setup() {
  Serial.begin(115200);
  Serial.println("Starting Arduino BLE Client application...");
  BLEDevice::init("");

  // Retrieve a Scanner and set the callback we want to use to be informed when we
  // have detected a new device.  Specify that we want active scanning and start the
  // scan to run for 5 seconds.
  BLEScan *pBLEScan = BLEDevice::getScan();
  pBLEScan->setAdvertisedDeviceCallbacks(new MyAdvertisedDeviceCallbacks());
  pBLEScan->setInterval(1349);
  pBLEScan->setWindow(449);
  pBLEScan->setActiveScan(true);
  pBLEScan->start(5, false);
}  // End of setup.

// This is the Arduino main loop function.
void loop() {

  // If the flag "doConnect" is true then we have scanned for and found the desired
  // BLE Server with which we wish to connect.  Now we connect to it.  Once we are
  // connected we set the connected flag to be true.
  if (doConnect == true) {
    if (connectToServer()) {
      Serial.println("We are now connected to the BLE Server.");
    } else {
      Serial.println("We have failed to connect to the server; there is nothing more we will do.");
    }
    doConnect = false;
  }

  // If we are connected to a peer BLE Server, update the characteristic each time we are reached
  // with the current time since boot.
  if (connected) {
    String newValue = "Time since boot: " + String(millis() / 1000);
    Serial.println("Setting new characteristic value to \"" + newValue + "\"");

    // Set the characteristic's value to be the array of bytes that is actually a string.
    pRemoteCharacteristic->writeValue(newValue.c_str(), newValue.length());
  } else if (doScan) {
    BLEDevice::getScan()->start(0);  // this is just example to start scan after disconnect, most likely there is better way to do it in arduino
  }

  delay(1000);  // Delay a second between loops.
}  // End of loop

How about using ESP-NOW for the data-exchange
The code below is written for ESP32-core Version 2.0.14. It will not work on ESP32-core-version 3.X because esspressif made breaking changes in the upgrade to ESP32-core 3.X

// keep this variable on top to have an easy to remember place to look up
// which board shall have this code-version /a short name is easier to remember as
// a MAC-Adress
char BoardName[] = "Bonny"; 

unsigned long SendDataInterval;
int Summand;

// at the end of this file there is an explanation that uses an analogy with 
// sending / receiving postal letters to explain the several parts of ESP-NOW
// so if you would like to have an easy to understand overview read this
// explanation first.

#include <WiFi.h>
#include <esp_now.h>

// For sending / receiving ESP-NOW-data on both sides sender and receiver;
// a structured variable has to be defined with the EXACT SAME structure

// the information that shall be sended/received is transmitted bytewise
// the ESP-NOW-functions know nothing about what the bytes mean
// it just transfers a specified number of bytes
// If the structures do not match 100% the data gets MIS-interpreted on the receiver-side
// in this demo-code a structure with an Array Of Char and an integer is used
// it is defined as follows

// ESP-NOW data-structure-definition
typedef struct MyESP_NOW_Data_type {
  char MyESP_NOW_MsgStr[128];
  int  MyESP_NOW_Int;
} MyESP_NOW_Data_type;

// After defining the structure two variables 
// one for receiving one for sending data
// this demo wants to demonstrate send AND receive in both directions
MyESP_NOW_Data_type my_received_ESP_NOW_Data;      
MyESP_NOW_Data_type my_READYtoSEND_ESP_NOW_Data;   


//#############################################################################################
// list of MAC-Adresses of all receivers:

// important note: ESP-NOW sending is based on the RECEIVERS Mac-adress.
// this means for every ESP-modul that shall receive an ESP-NOW-Messages
// you have to execute register a peer in the Setup-function

// Mac-Adress must be stored in an array of uint8_t
//uint8_t ESP_NOW_MAC_adrOfRecv[] = {0x78, 0xE3, 0x6D, 0x17, 0x3A, 0x20 };
uint8_t ESP_NOW_MAC_adrOfRecv[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF };


char MAC_adrOfRecv_as_AoC[18];
char Own_MacAdr_AoC[18];  //suffix _AoC for easier remembering variable-type is ArrayOfChar

char MySelfHeader[]   = "Myself: ";
char ReceivedHeader[] = "-------------->"; 
//##############################################################################################

//ESP_NOW_functions

// callback function that will be executed when data is received
// Parameters:
// mac:          mac-adress of sender
// incomingData: the bytes that are received
// NoOfBytesRcv: number of bytes received

//void OnDataRecv(uint8_t * mac, uint8_t *incomingData, uint8_t NoOfBytesRcv) { 
void OnDataRecv(const uint8_t * mac, const uint8_t *incomingData, int NoOfBytesRcv) {
  //char MacAdr[18];
  Serial.print(ReceivedHeader);
  Serial.print("ESP-NOW-Data received from Board with MAC-Adress#");
  Serial.print( strupr(hex02str(mac[0])));
  Serial.print(":");
  Serial.print( strupr(hex02str(mac[1])));
  Serial.print(":");
  Serial.print( strupr(hex02str(mac[2])));
  Serial.print(":");
  Serial.print( strupr(hex02str(mac[3])));
  Serial.print(":");
  Serial.print( strupr(hex02str(mac[4])));
  Serial.print(":");
  Serial.print( strupr(hex02str(mac[5])));
  Serial.print(":");
  Serial.println("#");
  
  // copy data bytewise from variable incomingData to variable my_received_ESP_NOW_Data
  memcpy(&my_received_ESP_NOW_Data, incomingData, sizeof(my_received_ESP_NOW_Data));

  Serial.print(ReceivedHeader);
  Serial.print("No of Bytes received: ");
  Serial.println(NoOfBytesRcv);

  //these lines must match the variables inside the ESP_NOW-data-structure
  Serial.print(ReceivedHeader);
  Serial.print("Received Msg: #"); // leading "#"
  Serial.print(my_received_ESP_NOW_Data.MyESP_NOW_MsgStr);
  Serial.println("#"); // trailing "#" makes it easy to see which bytes where received
  
  Serial.print(ReceivedHeader);
  Serial.print("Int: ");
  Serial.println(my_received_ESP_NOW_Data.MyESP_NOW_Int);

  Serial.println();
}


// callback when data is sent. Gets executed when sending data has finished
void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
  Serial.println();
  Serial.println();
  Serial.print(MySelfHeader);
  Serial.print(" OnDataSent Send Status: ");
  if (status == ESP_NOW_SEND_SUCCESS) {    
    Serial.println("Success'");
    Serial.println();
  }
  else {
    Serial.println("Failed'");
    Serial.println();
  }  
}

// attention! for some unknown strange reasons the variable 
// for the peer-info has to be global otherwise you will 
// get the error "ESPNOW: Peer Interface is invalid"
esp_now_peer_info_t MyPeerInfo; 

void ESP_Now_setup() {
  WiFi.mode(WIFI_STA);
  Serial.println("WiFi.mode(WIFI_STA); done");
  WiFi.disconnect(); // for strange reasons WiFi.disconnect() makes ESP-NOW work
  Serial.println("WiFi.disconnect(); done");
  
  // Init ESP-NOW  
  if (esp_now_init() != ESP_OK) {
    Serial.println("Error initializing ESP-NOW");
    return;
  }  
  Serial.println("esp_now_init() was successful");
    
  // register callback-function that will be executed each time 
  // function esp_now_send() has finished
  esp_now_register_send_cb(OnDataSent);
  Serial.println("esp_now_register_send_cb(OnDataSent); done");
  
  // register callback-function that will be executed each time
  // ESP-NOW-Data is received
  esp_now_register_recv_cb(OnDataRecv);  
  Serial.println("esp_now_register_recv_cb(OnDataRecv); done");

  // the ESP-NOW-Sender needs to "fill out" a list with informations about each receiver
  // this is called peer. Therefore you have to create a variable of type esp_now_peer_info_t
  //esp_now_peer_info_t MyPeerInfo;
  // then "fill out" peer-data-form
  memcpy(MyPeerInfo.peer_addr, ESP_NOW_MAC_adrOfRecv, 6);
  MyPeerInfo.channel = 0;  
  MyPeerInfo.encrypt = false;
  
  // after setting up peer-info, add peer        
  if (esp_now_add_peer(&MyPeerInfo) != ESP_OK){
    Serial.println("Failed to add peer");
    return;
  }
  Serial.println("esp_now_add_peer(&peerInfo) was successful");
  // this setup peer-info and add peer has to be repeated for each receiver 
  // that shall receive from this sender

  for (int i = 0;   i < 6;  i++) {
    strcat (MAC_adrOfRecv_as_AoC, hex02str(ESP_NOW_MAC_adrOfRecv[i])   );
    if (i < 6) {
      strcat (MAC_adrOfRecv_as_AoC, ":" );    
    }  
  }  
  MAC_adrOfRecv_as_AoC[17] = 0;
  strupr(MAC_adrOfRecv_as_AoC); // make letters UPPERCASE
  Serial.print("MAC-Adress of Receiver is ");
  Serial.println(MAC_adrOfRecv_as_AoC);
}


void ESP_NOW_SendData()
{
  char AoC[10];
  // Set values to send
  strcpy(my_READYtoSEND_ESP_NOW_Data.MyESP_NOW_MsgStr, "HI I'M SENDING EVERY ");
  itoa(SendDataInterval,AoC,10);
  strcat (my_READYtoSEND_ESP_NOW_Data.MyESP_NOW_MsgStr,AoC );
  strcat (my_READYtoSEND_ESP_NOW_Data.MyESP_NOW_MsgStr, " Milli-SECONDS COUNTiNG UP ");
  itoa(Summand,AoC,10);
  strcat (my_READYtoSEND_ESP_NOW_Data.MyESP_NOW_MsgStr,AoC );
  my_READYtoSEND_ESP_NOW_Data.MyESP_NOW_Int = my_READYtoSEND_ESP_NOW_Data.MyESP_NOW_Int + Summand; 

  // Send message via ESP-NOW  
  esp_now_send(ESP_NOW_MAC_adrOfRecv, (uint8_t *) &my_READYtoSEND_ESP_NOW_Data, sizeof(my_READYtoSEND_ESP_NOW_Data));
  Serial.print(MySelfHeader);
  Serial.println("esp_now_send(ESP_NOW_MAC_adrOfRecv, (uint8_t *) &my_READYtoSEND_ESP_NOW_Data, sizeof(my_READYtoSEND_ESP_NOW_Data)); done");
  Serial.print(MySelfHeader);
  Serial.print("I am the board named '");
  Serial.print(BoardName);
  Serial.print("' with the MAC-Adress ");
  Serial.println(Own_MacAdr_AoC); 
  Serial.print(MySelfHeader);
  Serial.print("and I try to send my ESP-NOW-Data to the board with MAC-Adress #");
  Serial.print(MAC_adrOfRecv_as_AoC); 
  Serial.println("#"); 
  
  // if sending has finished function OnDataSent is called
}


/* nonblocking timing based on millis()
this function returns true each time the TimePeriod has expired and immediately 
starts a new TimePeriod. So this function
example-use:
unsigned long myTimer;

  if (TimePeriodIsOver( myTimer,2000) ) {
    //the code here gets executed only every 2000 Milliseconds
  }  
*/  
boolean TimePeriodIsOver (unsigned long &expireTime, unsigned long TimePeriod) {
  unsigned long currentMillis  = millis();
  if ( currentMillis - expireTime >= TimePeriod )
  {
    expireTime = currentMillis; // set new expireTime
    return true;                // more time than TimePeriod) has elapsed since last time if-condition was true
  } 
  else return false;            // not expired
}

unsigned long SendDataTimer;


/*
everytime the compiler compiles the file new 
the macros named "__FILE__",  "__DATE__", "__TIME__"  
where replaced with what their names say  Filename with path
Date and time of the Computer the IDE is running on 
// so by simply starting the device the uploaded code yells filename date and time at the serial interface
*/
void PrintWiFiMacAdress()
{
  char HexByteDigits[3];
  
  for (uint8_t i = 0; i < 18; i = i + 1)
  { 
    Own_MacAdr_AoC[i] = WiFi.macAddress()[i];
  } 
  Own_MacAdr_AoC[17] = 0; // zero to terminate the string
  Serial.print("ESP32-Board's OWN MAC-Address is:  ");
  Serial.println(Own_MacAdr_AoC);
  
  Serial.println();
  Serial.println("copy the line below and replace the codeline");
  Serial.println("uint8_t ESP_NOW_MAC_adrOfRecv[] = { .... };");
  Serial.println("inside the code with the copied line from the serial monitor");
   
  Serial.println();
  Serial.print("uint8_t ESP_NOW_MAC_adrOfRecv[] = {");
  for (uint8_t i = 0; i < 16; i = i + 3)
  { 
    HexByteDigits[0] = Own_MacAdr_AoC[i];
    HexByteDigits[1] = Own_MacAdr_AoC[i+1];
    HexByteDigits[2] = 0; // zero for terminating the string
    Serial.print("0x");
    Serial.print(HexByteDigits);
    if (i < 14) Serial.print(", ");
  }  
  Serial.println(" };");
  Serial.println();      
}

char* hex02str(uint8_t b)  {
 static char str[]="FF"; // RAM für einen 2-Zeichen string reservieren.
  snprintf(str,sizeof(str),"%02x",b);
  return str;
}


void PrintFileNameDateTime()
{
  Serial.print("Code running comes from file ");
  Serial.println(__FILE__);
  Serial.print(" compiled ");
  Serial.print(__DATE__);
  Serial.print("  ");
  Serial.println(__TIME__);  
}

  
void setup() {
  // Init Serial Monitor
  Serial.begin(115200);
  Serial.println();  // a carriage return to make sure serial-output begins in colum 1 of a new line
  PrintFileNameDateTime();
  PrintWiFiMacAdress();  

  ESP_Now_setup();

}

void loop() {
  SendDataInterval = 5000;
  Summand = 6;
  // check if timer-intervall is over
  if (TimePeriodIsOver(SendDataTimer,SendDataInterval) )  
    {
     Serial.print(MySelfHeader); 
     Serial.println("SendData");
     ESP_NOW_SendData();
     Serial.print(MySelfHeader);
     Serial.println("SendData done");
    }
}

// explanation how ESP-NOW works:
// sending serial data is as easy as

// Serial.begin(baudrate);
// Serial.print("Hello World");

// This can be coded so easy because a lot of things are fixed
// IO-Pins used by Serial fixed
// Connection to other device: made of wire must not be programmed
// standard serial-connection does not care if receiver is connected
// send data and that's all

// with ESP-NOW more things are NOT fixed = are adjustable and MUST be adjusted
// the receiver is NOT defined by wire but through his MAC-Adress

// Data-transport can do only transporting one or multiple bytes
// That's not a real problem the user can define his own data-pattern (data-structure)

// ESP-NOW offers feedback if a datatransmission was successful or failed

// each ESP-modul can receive data from different senders 
// so an information from WHICH sender the data was sended is useful
// this is identified by the senders MAC-Adress

// Analogy sending / receiving postal letters:

// if you want to SEND out a letter to somebody you have to write 
// the receivers adress on the envelope. 
// similar thing with ESP-NOW: the data is NOT guided by a wire its sended "Up in the air" 
// with ESP-NOW this receiver-adress is the MAC-Adress of the receiver

// if the postman brings you a letter you don't have to be at home. He will put the letter
// into your letter-box and you can pick it out later.

// similar thing with ESP-NOW: there is some kind of "letter-box" the received data will be stored into
// the data-receiving into the "letter-box" runs in the backround similar to an interrupt
// The received data will be catched up and must be copied into some kind of "letterbox"

// any transporting service has some kind of procedure if you want to send a package or a letter
// fill out some kind of form (on paper or onnline) print out and glue a sticker 
// with the transporting informations on the package/envelope put the package / envelope into the
// "send out" box or bring it to a pickup store

// so there are some things to do:

// - build a special designed "package" with multiple special designed compartments
//   where all parts of the data has its own compartment 
//   defining a new variable type "structure"

// - build a "letter-box" designed expecially for the user-defined package 
//   and "mount it in front of your house" so it can be easily found by the postman

// - write a list with receiver-adresses and handover this list to ESP-NOW (the "letter-department" of your "company"

// - setup if data shall be encrypted or not

// - setup transmission channel that shall be used for sending the data wireless

// - design your "sending-form"

// these are the reasons why ESP-NOW needs a bit more than just
// Serial.begin(baudrate);
// Serial.print("My Data");

// Your mobile phone has a lot of functions. It took you some time to learn them all
// Same thing with ESP-NOW it will take some time to learn it

// if you want it fully automated without learning anything 
// use Alexa, Siri or google assistant instead

ESP-NOW sounds like a good solution for what I want, but I'm having trouble getting the code to work. I'm not sure if this is an ESP-NOW problem, or something else.

Basically, the first time I run the code, I get the expected output in the Serial Monitor, but if I press the Reset button on the board, I don't get the expected output again.

I'm using the example code in File > Examples > Examples for XIAO_ESP32C3>ESP_NOW>ESP_Broadcast_Slave (full code copied below)

Here's the expected output (which is what I get after I first upload the code and switch over to Serial Monitor):

ESP-NOW Example - Broadcast Slave
Wi-Fi parameters:
Mode: STA
MAC Address: 40:4C:CA:F4:DE:38
Channel: 6
Setup complete. Waiting for a master to broadcast a message...

But then when I press the Reset button on my ESP32C3 board, the Serial Monitor is blank. I think I should see the above message repeated as the code starts over again, but I don't. What's going on?

ESP_NOW_Broadcast_Slave code:

/*
    ESP-NOW Broadcast Slave
    Lucas Saavedra Vaz - 2024

    This sketch demonstrates how to receive broadcast messages from a master device using the ESP-NOW protocol.

    The master device will broadcast a message every 5 seconds to all devices within the network.

    The slave devices will receive the broadcasted messages. If they are not from a known master, they will be registered as a new master
    using a callback function.
*/

#include "ESP32_NOW.h"
#include "WiFi.h"

#include <esp_mac.h>  // For the MAC2STR and MACSTR macros

#include <vector>

/* Definitions */

#define ESPNOW_WIFI_CHANNEL 6

/* Classes */

// Creating a new class that inherits from the ESP_NOW_Peer class is required.

class ESP_NOW_Peer_Class : public ESP_NOW_Peer {
public:
  // Constructor of the class
  ESP_NOW_Peer_Class(const uint8_t *mac_addr, uint8_t channel, wifi_interface_t iface, const uint8_t *lmk) : ESP_NOW_Peer(mac_addr, channel, iface, lmk) {}

  // Destructor of the class
  ~ESP_NOW_Peer_Class() {}

  // Function to register the master peer
  bool add_peer() {
    if (!add()) {
      log_e("Failed to register the broadcast peer");
      return false;
    }
    return true;
  }

  // Function to print the received messages from the master
  void onReceive(const uint8_t *data, size_t len, bool broadcast) {
    Serial.printf("Received a message from master " MACSTR " (%s)\n", MAC2STR(addr()), broadcast ? "broadcast" : "unicast");
    Serial.printf("  Message: %s\n", (char *)data);
  }
};

/* Global Variables */

// List of all the masters. It will be populated when a new master is registered
std::vector<ESP_NOW_Peer_Class> masters;

/* Callbacks */

// Callback called when an unknown peer sends a message
void register_new_master(const esp_now_recv_info_t *info, const uint8_t *data, int len, void *arg) {
  if (memcmp(info->des_addr, ESP_NOW.BROADCAST_ADDR, 6) == 0) {
    Serial.printf("Unknown peer " MACSTR " sent a broadcast message\n", MAC2STR(info->src_addr));
    Serial.println("Registering the peer as a master");

    ESP_NOW_Peer_Class new_master(info->src_addr, ESPNOW_WIFI_CHANNEL, WIFI_IF_STA, NULL);

    masters.push_back(new_master);
    if (!masters.back().add_peer()) {
      Serial.println("Failed to register the new master");
      return;
    }
  } else {
    // The slave will only receive broadcast messages
    log_v("Received a unicast message from " MACSTR, MAC2STR(info->src_addr));
    log_v("Igorning the message");
  }
}

/* Main */

void setup() {
  Serial.begin(115200);
  while (!Serial) {
    delay(10);
  }

  // Initialize the Wi-Fi module
  WiFi.mode(WIFI_STA);
  WiFi.setChannel(ESPNOW_WIFI_CHANNEL);
  while (!WiFi.STA.started()) {
    delay(100);
  }

  Serial.println("ESP-NOW Example - Broadcast Slave");
  Serial.println("Wi-Fi parameters:");
  Serial.println("  Mode: STA");
  Serial.println("  MAC Address: " + WiFi.macAddress());
  Serial.printf("  Channel: %d\n", ESPNOW_WIFI_CHANNEL);

  // Initialize the ESP-NOW protocol
  if (!ESP_NOW.begin()) {
    Serial.println("Failed to initialize ESP-NOW");
    Serial.println("Reeboting in 5 seconds...");
    delay(5000);
    ESP.restart();
  }

  // Register the new peer callback
  ESP_NOW.onNewPeer(register_new_master, NULL);

  Serial.println("Setup complete. Waiting for a master to broadcast a message...");
}

void loop() {
  delay(1000);
}

EDIT: BTW, thank you very much for the example code you provided! When I tried compiling it I got the below error, so that's why I used the above example code.

Error message when compiling ESP_NOW_ExampleFromStefanL38:

C:\Users\BFreese_Laptop\Documents\Arduino\ESP_NOW_ExampleFromStefanL38\ESP_NOW_ExampleFromStefanL38.ino: In function 'void ESP_Now_setup()':
C:\Users\BFreese_Laptop\Documents\Arduino\ESP_NOW_ExampleFromStefanL38\ESP_NOW_ExampleFromStefanL38.ino:148:28: error: invalid conversion from 'void (*)(const uint8_t*, const uint8_t*, int)' {aka 'void (*)(const unsigned char*, const unsigned char*, int)'} to 'esp_now_recv_cb_t' {aka 'void (*)(const esp_now_recv_info*, const unsigned char*, int)'} [-fpermissive]
  148 |   esp_now_register_recv_cb(OnDataRecv);
      |                            ^~~~~~~~~~
      |                            |
      |                            void (*)(const uint8_t*, const uint8_t*, int) {aka void (*)(const unsigned char*, const unsigned char*, int)}
In file included from C:\Users\BFreese_Laptop\Documents\Arduino\ESP_NOW_ExampleFromStefanL38\ESP_NOW_ExampleFromStefanL38.ino:15:
C:\Users\BFreese_Laptop\AppData\Local\Arduino15\packages\esp32\tools\esp32-arduino-libs\idf-release_v5.1-bd2b9390ef\esp32c3/include/esp_wifi/include/esp_now.h:157:54: note:   initializing argument 1 of 'esp_err_t esp_now_register_recv_cb(esp_now_recv_cb_t)'
  157 | esp_err_t esp_now_register_recv_cb(esp_now_recv_cb_t cb);
      |                                    ~~~~~~~~~~~~~~~~~~^~

exit status 1

Compilation error: invalid conversion from 'void (*)(const uint8_t*, const uint8_t*, int)' {aka 'void (*)(const unsigned char*, const unsigned char*, int)'} to 'esp_now_recv_cb_t' {aka 'void (*)(const esp_now_recv_info*, const unsigned char*, int)'} [-fpermissive]

After continuing to play around with ESP_NOW I don't think it's going to work for my project. Thank you for suggesting it though.

I'm now back to my original question, how can I send 1 byte from a BLE UART Client back to my BLE UART Server. I can do it when I connect to the UART server using nRF Connect on a phone. How do I program another ESP32C3 to do it too?

Thank you!

Here's the code that got this part working.
For the BLE Client, use the "UART Server" code from my first post, then plug it into the wall so it's running. (Also add the red and green LEDs as explained there.)
On the second Seeed Xiao ESP32-C3, use the code below.

The UART Server code sets up 2 characteristics for the BLE Service. One labeled Tx for sending messages, and one labeled Rx for receiving messages. The green LED turns on when a client has connected. The red LED turns on when it receives the letter A in the Rx characteristic.

Here are the changes I made to the client code:
First add the UUID of the server's Rx characteristic

// The RX Characteristic of the remote service.
static BLEUUID rxUUID("6E400002-B5A3-F393-E0A9-E50E24DCCA9E");

Then add a new pointer

static BLERemoteCharacteristic *pRemoteRxCharacteristic;

Then after the setup code obtains a reference to the Service, link that pointer to the Rx characteristic.

pRemoteRxCharacteristic = pRemoteService->getCharacteristic(rxUUID);
  if (pRemoteRxCharacteristic == nullptr){
    Serial.println("Failed to get Rx Characteristic");
    return false;
  } else{ 
    Serial.println("Got Rx Characteristic");
  }

Finally, in the loop, I added

  if (connected) {
    if(hasSentA == 0){   // hasSentA is an int where 0 == no and 1 == yes
      pRemoteRxCharacteristic->writeValue('A',sizeof('A'));
      hasSentA = 1;
    }
  }

When the code ran on my other ESP32C3 the red and the green LEDs on the Server came right on.

Client_for_UART_ESP32C3 (full code)

/**
 * A BLE client example that is rich in capabilities.
 * There is a lot new capabilities implemented.
 * author unknown
 * updated by chegewara
 */

#include "BLEDevice.h"
//#include "BLEScan.h"

int hasSentA = 0;

// The remote service we wish to connect to.
static BLEUUID serviceUUID("6E400001-B5A3-F393-E0A9-E50E24DCCA9E");
// The characteristic of the remote service we are interested in.
static BLEUUID charUUID("6E400003-B5A3-F393-E0A9-E50E24DCCA9E");
// The RX Characteristic of the remote service.
static BLEUUID rxUUID("6E400002-B5A3-F393-E0A9-E50E24DCCA9E");

static boolean doConnect = false;
static boolean connected = false;
static boolean doScan = false;
static BLERemoteCharacteristic *pRemoteCharacteristic;
static BLERemoteCharacteristic *pRemoteRxCharacteristic;
static BLEAdvertisedDevice *myDevice;

static void notifyCallback(BLERemoteCharacteristic *pBLERemoteCharacteristic, uint8_t *pData, size_t length, bool isNotify) {
  Serial.print("Notify callback for characteristic ");
  Serial.print(pBLERemoteCharacteristic->getUUID().toString().c_str());
  Serial.print(" of data length ");
  Serial.println(length);
  Serial.print("data: ");
  Serial.write(pData, length);
  Serial.println();
}

class MyClientCallback : public BLEClientCallbacks {
  void onConnect(BLEClient *pclient) {}

  void onDisconnect(BLEClient *pclient) {
    connected = false;
    Serial.println("onDisconnect");
  }
};

bool connectToServer() {
  Serial.print("Forming a connection to ");
  Serial.println(myDevice->getAddress().toString().c_str());

  BLEClient *pClient = BLEDevice::createClient();
  Serial.println(" - Created client");

  pClient->setClientCallbacks(new MyClientCallback());

  // Connect to the remove BLE Server.
  pClient->connect(myDevice);  // if you pass BLEAdvertisedDevice instead of address, it will be recognized type of peer device address (public or private)
  Serial.println(" - Connected to server");
  pClient->setMTU(517);  //set client to request maximum MTU from server (default is 23 otherwise)

  // Obtain a reference to the service we are after in the remote BLE server.
  BLERemoteService *pRemoteService = pClient->getService(serviceUUID);
  if (pRemoteService == nullptr) {
    Serial.print("Failed to find our service UUID: ");
    Serial.println(serviceUUID.toString().c_str());
    pClient->disconnect();
    return false;
  }
  Serial.println(" - Found our service");

  // Obtain a reference to the characteristic in the service of the remote BLE server.
  pRemoteCharacteristic = pRemoteService->getCharacteristic(charUUID);
  if (pRemoteCharacteristic == nullptr) {
    Serial.print("Failed to find our characteristic UUID: ");
    Serial.println(charUUID.toString().c_str());
    pClient->disconnect();
    return false;
  }
  Serial.println(" - Found our characteristic");

  pRemoteRxCharacteristic = pRemoteService->getCharacteristic(rxUUID);
  if (pRemoteRxCharacteristic == nullptr){
    Serial.println("Failed to get Rx Characteristic");
    return false;
  } else{ 
    Serial.println("Got Rx Characteristic");
  }

  // Read the value of the characteristic.
  if (pRemoteCharacteristic->canRead()) {
    String value = pRemoteCharacteristic->readValue();
    Serial.print("The characteristic value was: ");
    Serial.println(value.c_str());
  }

  if (pRemoteCharacteristic->canNotify()) {
    pRemoteCharacteristic->registerForNotify(notifyCallback);
  }

  connected = true;
  return true;
}
/**
 * Scan for BLE servers and find the first one that advertises the service we are looking for.
 */
class MyAdvertisedDeviceCallbacks : public BLEAdvertisedDeviceCallbacks {
  /**
   * Called for each advertising BLE server.
   */
  void onResult(BLEAdvertisedDevice advertisedDevice) {
    Serial.print("BLE Advertised Device found: ");
    Serial.println(advertisedDevice.toString().c_str());

    // We have found a device, let us now see if it contains the service we are looking for.
    if (advertisedDevice.haveServiceUUID() && advertisedDevice.isAdvertisingService(serviceUUID)) {

      BLEDevice::getScan()->stop();
      myDevice = new BLEAdvertisedDevice(advertisedDevice);
      doConnect = true;
      doScan = true;

    }  // Found our server
  }  // onResult
};  // MyAdvertisedDeviceCallbacks

void setup() {
  Serial.begin(115200);
  Serial.println("Starting Arduino BLE Client application...");
  BLEDevice::init("");

  // Retrieve a Scanner and set the callback we want to use to be informed when we
  // have detected a new device.  Specify that we want active scanning and start the
  // scan to run for 5 seconds.
  BLEScan *pBLEScan = BLEDevice::getScan();
  pBLEScan->setAdvertisedDeviceCallbacks(new MyAdvertisedDeviceCallbacks());
  pBLEScan->setInterval(1349);
  pBLEScan->setWindow(449);
  pBLEScan->setActiveScan(true);
  pBLEScan->start(5, false);
}  // End of setup.

// This is the Arduino main loop function.
void loop() {

  // If the flag "doConnect" is true then we have scanned for and found the desired
  // BLE Server with which we wish to connect.  Now we connect to it.  Once we are
  // connected we set the connected flag to be true.
  if (doConnect == true) {
    if (connectToServer()) {
      Serial.println("We are now connected to the BLE Server.");
    } else {
      Serial.println("We have failed to connect to the server; there is nothing more we will do.");
    }
    doConnect = false;
  }

  // If we are connected to a peer BLE Server, update the characteristic each time we are reached
  // with the current time since boot.
  if (connected) {
    String newValue = "Time since boot: " + String(millis() / 1000);
    Serial.println("Setting new characteristic value to \"" + newValue + "\"");

    // Set the characteristic's value to be the array of bytes that is actually a string.
    pRemoteCharacteristic->writeValue(newValue.c_str(), newValue.length());
  } else if (doScan) {
    BLEDevice::getScan()->start(0);  // this is just example to start scan after disconnect, most likely there is better way to do it in arduino
  }

  if (connected) {
    if(micros()>5000 && hasSentA == 0){
      pRemoteRxCharacteristic->writeValue('A',sizeof('A'));
      hasSentA = 1;
    }
  }

  delay(1000);  // Delay a second between loops.
}  // End of loop

Many thanks to shooks12 at the esp32 forum for posting the answer!
https://esp32.com/viewtopic.php?t=17397#