Random, intermittent CMD ERROR - Arduino UNO WiFi Rev2, and virtual pin problem

This is an Arduino UNO WiFi Rev2, also connected for development to an iMac via USB for loading sketches and debug output. App is on iPhone.

I’m getting CMD ERROR randomly. The sketch runsfor an indeterminate amount of time, then loses connectivity. The normal debug print is this

[671695] <[14|0A|92|00|06]vw[00]0[00]0
[671804] <[14|0A|93|00|06]vw[00]1[00]0
[672021] <[14|0A|94|00|1C]vw[00]2[00]p[00]0[00]0[00]Pump – 07/30/20[00]
[672097] <[14|0A|95|00|1C]vw[00]2[00]p[00]0[00]1[00] 11m 12s [00]

and the CMD ERROR I’ve captured did this:

[673699] <[14|0A|9B|00|06]vw[00]0[00]0
[697016] <[14|0A|9C|00|06]vw[00]1[00]0
[697017] Cmd error

I read that as writing to V0 and 1, then failing without doing the V2 write. That’s a 16x2 LCD, and I send 16-character strings to both lines with separate calls.

The housekeeping code looks like this:

// ---------------------------------------------------------------
// Interface to Blynk.connected(). It's here to provide a testing injection of mock
// disconnected WiFi. The WiFi will be forced to think it's disconnected as long as
// left button is down.
//
bool isConnected() {
  bool left = haveButton(BUTTON_LEFT);
  return !left && Blynk.connected();
}

// ---------------------------------------------------------------
// Blynk callout for body of while true {}. Runs Blynk, timer, time update, and local loop
// tick. This depends on connection monitoring elsewhere (timer, see wifisetup and
// reconnectBlynk)
//
void loop() {
  timer.run();
  if (isConnected()) {
    Blynk.run();
  }

  y2kSeconds = time(NULL);
  loopTick();
}

// ---------------------------------------------------------------
// Called off on a timer. If the connection has gone down, this restores it. It's used
// as an alternative to the run loop blocking, to ensure the local display gets
// updated.
//
void reconnectBlynk() {
  if (!isConnected()) {
    Serial.println("Lost connection");
    if (Blynk.connect()) {
      Serial.println("Reconnected");
    }
    else {
      Serial.println("Not reconnected");
    }
  }
}

// ---------------------------------------------------------------
// Blynk callout for initialization. Sets serial port data rate, initializes WiFi, and
// calls for app setup.
//
void setup() {
  Serial.begin(9600);
  Serial.println("Beginning setup...");
  wifiSetup();
  applicationSetup();
  Serial.println("...setup complete");
}

// ---------------------------------------------------------------
// Initialize Blynk WiFi link. Handles authentication.
//
void wifiSetup() {
  static const char auth[] = "#####################";
  static const char ssid[] = "###########";
  static const char pass[] = "#############";
  Blynk.begin(auth, ssid, pass);
  while (Blynk.connect() == false) {  }           // Wait until connected
  timer.setInterval(60 * 1000, reconnectBlynk);   // check every minute if still connected
}

I still get Serial.println output after connected() starts returning false (although that eventually dies), and I never see a “Lost connection” output indicating the call to reconnectBlynk happened (even though every pass through loop() does call timer.run(). The error above happened after running for 11 minutes, 13 seconds. I’m currently watching it pass 4 hours and stay connected.

The overall app structure is that it checks for a state change every pass through the loop; these errors occur when completely idle and so no state changes. The rest of the code happens off of one-second, five-second, and one minute timers. One second code blinks writes virtual LEDs (and one physical one), and writes the physical + virtual LCDs. Five second code changes the display on the second line and writes a short serial message. One minute code gets the time from the WiFi stack via WiFi_getTime(). There is no other code (albeit there’s code doing formatting I didn’t show or discuss)

The project is a remote control relay for my pool pump. The reason for structuring the code this way is that there’s an LCD stacked on the Arduino that I want to keep running even if the connection goes down so the controller is usable manually. I want to avoid the code hanging waiting for connect.

The compile/link says

Sketch uses 32215 bytes (66%) of program storage space. Maximum is 48640 bytes.
Global variables use 1282 bytes (20%) of dynamic memory, leaving 4862 bytes for local variables. Maximum is 6144 bytes.

so I don’t think I’m running out of memory, and I don’t see anywhere I’d be doing memory allocation that would lead to a memory exception. I’m formatting serial print messages with a call to vsnprint, allocating a 150 character buffer on the stack for the purpose, so I don’t suspect a memory issue from that.

I have noticed that the LCD can hang, preventing setup from completing, if the board is interrupted at an unfortunate time, but I don’t think that’s relevant here since when that happens a full power off is needed to reset the LCD shield (grumble).

I’ve run out of ideas where to look other than perhaps, and I’d really appreciate some help from any of the wizards here. I can detect the loss of connection state, of course, and (if I find the necessary magic incantation) could restart the board (I don’t see a way to implement a watchdog timer), but that’s an incredibly sloppy hack. I shouldn’t be getting/causing the error in the first place.

Thanks for looking!

Here’s the entire sketch:


// **************************************************************************************
/* Comment this out to disable prints and save space */
#define BLYNK_PRINT Serial // Defines the object that is used for printing
#define BLYNK_DEBUG        // Optional, this enables more detailed prints

#include <SPI.h>
#include <WiFiNINA.h>
#include <BlynkSimpleWiFiNINA.h>
#include <Adafruit_RGBLCDShield.h>
#include <time.h>
#include <util/usa_dst.h>

// **************************************************************************************
//
// Virtual pin usage
//
// V0 = LED heartbeat (output)
// V1 = Pump state (output)
// V2 = Virtual LCD (output)
// V3 = Pump on duration timer, in units of DURATION_STEP (input, 0 == no limit)
//
// Digital pin usage
//
// D11 - Loop tick
// D12 - Pump state (input from app, output to pump)
// D13 - LED_BUILTIN = heartbeat (output to LED)
//
// LCD Shield Buttons
//
// Left = simulate no WiFi
// Down = turn off backlight

// **************************************************************************************
//
// global constants
//
// Pin usage
static const int ledPin =  LED_BUILTIN;       // Heartbeat
static const int poolPin = 12;                // Drive to relays
static const int tickPin = 11;                // Toggle each time through loop()
static const int secondPin = 10;              // Toggle each second
static const int busyPin = 9;                 // High during active one second processing
// Mapping from internal status to relay drive. HIGH / LOW here is external requirement
static const int RELAY_OFF = LOW;
static const int RELAY_ON = HIGH;
// How long each step in the UI delays in the timer, in seconds (e.g., each step an hour)
static const int DURATION_STEP = 3600;
// Shield backlight
static const int  BACKLIGHT_OFF = 0x00;
static const int  BACKLIGHT_ON = 0x07;

//
// library access
//
Adafruit_RGBLCDShield lcd = Adafruit_RGBLCDShield();
WidgetLCD vlcd(V2);
BlynkTimer timer = BlynkTimer();

//
// global variables
//
bool ledState = false;            // Heartbeat high/low
int poolState = RELAY_OFF;        // Pump relay digital output
bool tickState = false;           // Loop transit logic analyzer output
bool secondState = false;         // One second toggle logic analyzer output
int pumpDuration = 0;             // What the stepper widget says, in DURATION_STEP seconds
int remainingPumpInterval = 0;    // Seconds left. Transition to zero is shutoff
bool showingUptime = false;       // True if uptime/remaining on second line, false if date
time_t y2kSeconds = 0;            // Current time since 1/1/2000, seconds
time_t startMillis = millis();    // Time when booted

// **************************************************************************************
// Timed functionality - These are the routines called every so often on a regular cycle.
// The intent is that they are dispatchers; the sum of time taken in each should not
// exceed the sum of the cycle rate intervals less the major cycle interval.

// --------------------------------------------------------------------------------------
// Runs every loop pass
//
// See if pool state has changed, if so copies new state to pump, writes notice
// to Serial, and updates LCD. Do not write to Blynk in this routine other than gated on
// variable change, and observe connected state.
//
void loopTick() {
  // Wiggle pin for logic analyzer
  tickState = !tickState;
  digitalWrite(tickPin, tickState ? HIGH : LOW);

  int localPoolState = digitalRead(poolPin);
  bool connected = isConnected();
  bool handleChange = !(checkForce(BUTTON_SELECT, RELAY_OFF, &localPoolState) || checkForce(BUTTON_UP, RELAY_ON, &localPoolState))
                      && connected
                      && localPoolState != poolState;
  if (handleChange) {
    // Enter here if there were no button forces and the status has changed.
    // In that case, localPoolState is what it's supposed to be.
    //
    // It's OK to do nothing if there's no connection, because if the pool is on
    // indefinitely then that's OK, and if on timer then clockTick() handles it.
    //
    poolState = localPoolState;
    bool off = poolState == RELAY_OFF;
    if (off) {
      turnOffPump();
    } else {
      turnOnPump();
    }

    writeLCDStatus();
    writeSerialStatus("loopTick");
  }
}

// --------------------------------------------------------------------------------------
// Runs every second
//
// Blinks the hardware and app heartbeat LEDs, and updates the LCD content.
//
void clockTick() {
  // Toggle pin for logic analyzer, shows when work is being done.
  digitalWrite(busyPin, HIGH);

  // Process count-down timer
  if (remainingPumpInterval > 0) {
    remainingPumpInterval -= 1;

    serialPrintln("Tick %d", remainingPumpInterval);

    if (remainingPumpInterval == 0) {
      turnOffPump();
    }
  }

  // Toggle one second logic analyzer pin, process LED and LCD
  secondState = !secondState;
  digitalWrite(secondPin, secondState ? HIGH : LOW);
  blinkLED();
  writeLCDStatus();

  // Processing complete, take down logic analyzer work indicator
  digitalWrite(busyPin, LOW);
}

// --------------------------------------------------------------------------------------
// Runs every 5 seconds
//
// Switches the LCD line 2 content
//
void clockTick5() {
  showingUptime = !showingUptime;
  writeSerialStatus("clockTick5 ");
}

// --------------------------------------------------------------------------------------
// Runs every minute.
//
// Resets the OS clock from WiFi (NTP) and writes complete status to serial port. Clock
// reset on periodic basis necesssary to eliminate drift. Being on one minute cycle is
// overkill for the purpose; the cycle time is driven by desire for serial status
//
void clockTick60() {
  readTime();
  writeSerialStatus("clockTick60");
}

// **************************************************************************************
// Functionality used above. Routines to accpmplish specific tasks
//

// --------------------------------------------------------------------------------------
// Toggle the LED state
//
// Switch the LED on/off, and output new state to the LED and the phone app. Also writes
// the pool state to the app to force synchronization since there's a delay vs. the button
// effect being propagated via digitalRead.
//
// Blynk documentation says don't hit the servers more than once per second or they'll
// disconnect.
//
void blinkLED() {
  ledState = !ledState;
  digitalWrite(ledPin, ledState ? HIGH : LOW);

  if (isConnected()) {
    Blynk.virtualWrite(V0, ledState ? 255 : 0);                  // Heartbeat
    Blynk.virtualWrite(V1, poolState == RELAY_OFF ? 0 : 255);    // Pump confirmation
  }
}

// --------------------------------------------------------------------------------------
// The Blynk.syncAll() command restores all the Widget’s values based on the last
// saved values on the server. All analog and digital pin states will be restored.
// Every Virtual Pin will perform BLYNK_WRITE event.
//
BLYNK_CONNECTED() {
  // Match up with server
  Blynk.syncAll();

  // Find out what the server thinks about the pump, set state to that
  poolState = digitalRead(poolPin);
  updatePool(poolState);

  // Sync button and LED virtual outputs to what the pool thinks
  Blynk.virtualWrite(V0, ledState ? LOW : HIGH);
  Blynk.virtualWrite(V1, poolState == RELAY_OFF ? 0 : 255);
  writeSerialStatus("blynk_connected");
}

// --------------------------------------------------------------------------------------
// Provides the stepper value for pump duration
//
BLYNK_WRITE(V3) {
  pumpDuration = param.asInt();
}

// --------------------------------------------------------------------------------------
// Center string *data* into buffer of length chars. returns buffer. Pads with fill
//
char* center(char* buffer, int bsize, char* data) {
  int chars = max(0, bsize - 1);
  memset(buffer, ' ', chars);
  buffer[chars] = '\0';
  int len = min(chars, max(0, strlen(data)));
  int col = (chars - len) / 2;
  memcpy(buffer + col, data, len);
  return buffer;
}

// --------------------------------------------------------------------------------------
// Examine the given button (it's a mask), and if it's down and that is not the current
// pool state, then force a change to that state. The state is done here regardless of
// connection.
//
// Returns true this call forced a pump state.
//
bool checkForce(uint8_t button, int state, int* localState) {
  int localPoolState = digitalRead(poolPin);
  bool wantOn = state == RELAY_ON;
  bool force = haveButton(button);
  bool execute = force && localPoolState != state;
  if (execute) {
    if (wantOn) {
      turnOnPump();
    } else {
      turnOffPump();
    }
    writeSerialStatus(wantOn ? "force on" : "force off");
  }
  return execute;
}

// --------------------------------------------------------------------------------------
// Construct the string to display the uptime. In the case of short form, the string is
// forcibly truncated at 16 characters. The lines of code are explicitly laid out and
// spaced as they are to take advantage of the parallelism in operations and so being
// able to eyeball for errors.
//
// Parameter:
// * longForm: set true to get words spelled out; otherwise, the returned string
//   abbreviates as w e h m s
//
char* formatDuration(long int seconds, bool longForm) {
  unsigned long int minutes = seconds  / 60;  seconds -= minutes * 60;
  unsigned long int hours   = minutes  / 60;  minutes -= hours   * 60;
  unsigned long int days    = hours    / 24;    hours -= days    * 24;
  unsigned long int weeks   = days     /  7;     days -= weeks   *  7;

  int chars = 0;
  char* prefix = "";
  char* spacer = longForm ? ", " : " ";
  static char buffer[100];

  bool printing = weeks > 0;
  if (printing)   {
    chars =  snprintf(buffer,         sizeof(buffer) - 1,         longForm ?   "%lu weeks"  :    "%luw",         weeks);
    prefix = spacer;
  }
  printing |= days > 0;
  if (printing)   {
    chars += snprintf(buffer + chars, sizeof(buffer) - 1 - chars, longForm ? "%s%lu days"    : "%s%lud", prefix, days);
    prefix = spacer;
  }
  printing |= hours > 0;
  if (printing)   {
    chars += snprintf(buffer + chars, sizeof(buffer) - 1 - chars, longForm ? "%s%lu hours"   : "%s%luh", prefix, hours);
    prefix = spacer;
  }
  printing |= minutes > 0;
  if (printing)   {
    chars += snprintf(buffer + chars, sizeof(buffer) - 1 - chars, longForm ? "%s%lu minutes" : "%s%lum", prefix, minutes);
    prefix = spacer;
  }
  if (weeks == 0) {
    chars += snprintf(buffer + chars, sizeof(buffer) - 1 - chars, longForm ? "%s%lu seconds" : "%s%lus", prefix, seconds);
  }
  if (!longForm) {
    buffer[16] = '\0';
  }
  return buffer;
}

// --------------------------------------------------------------------------------------
// Create LCD line "Pump xx mm/dd/yy"
//
char* formatLine0() {
  static char buffer[17];
  char* status;
  if      (!isConnected()       ) {
    status = "No WiFi";
  }
  else if (poolState == RELAY_ON) {
    status = "Pump on";
  }
  else                            {
    status = "Pump --";
  }
  snprintf(buffer, sizeof(buffer), "%s %s", status, formatTime(true, false));
  buffer[16] = '\0';
  return buffer;
}

// --------------------------------------------------------------------------------------
// Construct second line of LCD / VLCD display, with mm/dd/yy or uptime or remaining time
//
char* formatLine1() {
  static char line[17];
  if (showingUptime) {
    center(line, sizeof(line), formatUptime(false));
  } else if (remainingPumpInterval > 0) {
    center(line, sizeof(line), formatDuration(remainingPumpInterval, false));
  } else {
    center(line, sizeof(line), formatTime(false, true));
  }
  line[16] = '\0';
  return line;
}

// --------------------------------------------------------------------------------------
// Build string with local date/time
//
// Format is 07/20/20 14:28:18. Returns a pointer to a static character buffer
//
char* formatTime(bool withDate, bool withTime) {
  static char buffer[50];
  auto localtm = localtime(&y2kSeconds);
  char* format;
  if (withDate && withTime) {
    format = "%D %T";
  }
  else if (withDate) {
    format = "%D";
  }
  else if (withTime) {
    format = "%T";
  }
  else {
    format = "oops";
  }
  strftime(buffer, sizeof(buffer), format, localtm);
  return buffer;
}

// --------------------------------------------------------------------------------------
// Construct the string to display the uptime. In the case of short form, the string is
// forcibly truncated at 16 characters. The lines of code are explicitly laid out and
// spaced as they are to take advantage of the parallelism in operations and so being
// able to eyeball for errors.
//
// Parameter:
// * longForm: set true to get words spelled out; otherwise, the returned string
//   abbreviates as w e h m s
//
char* formatUptime(bool longForm) {
  return formatDuration((millis() - startMillis) / 1000L, longForm);
}

// --------------------------------------------------------------------------------------
// Find out if a button is pushed, using BUTTON_LEFT and friends
//
bool haveButton(uint8_t mask) {
  uint8_t buttons = lcd.readButtons();
  bool result = (buttons & mask) != 0;
  return result;
}

// --------------------------------------------------------------------------------------
// Setup specific to hardware configuration, timing, and signon
//
void localSetup() {
  Serial.println("Begin localSetup");

  digitalWrite(secondPin, HIGH);
  pinMode(secondPin, OUTPUT);

  digitalWrite(ledPin, HIGH);
  pinMode(ledPin, OUTPUT);

  digitalWrite(tickPin, HIGH);
  pinMode(tickPin, OUTPUT);

  digitalWrite(busyPin, HIGH);
  pinMode(busyPin, OUTPUT);

  // It's desirable that if there's a power failure or the board resets that it
  // initializes to having the pump off (i.e., the timeclock is in control).
  // The power relays are active low, while the test iotrelay is active high
  // (but has both NO and NC outlets). In order to power on in the off state,
  // this code has to set the off state to the pin (RELAY_OFF, defined as HIGH)
  // and then write enable the pin.
  poolState = RELAY_OFF;
  digitalWrite(poolPin, RELAY_OFF);
  pinMode(poolPin, OUTPUT);
  Serial.println("Finished pin setup");

  // set up the LCD's number of columns and rows, then write to displays and serial
  lcd.begin(16, 2);
  writeLCDStatus();
  Serial.println("Finished LCD setup");

  writeSerialStatus("localSetup");
  Serial.println("Finished display setup");

  // Periodic event setup. The WiFi reconnect timer is in wifiSetup(), so it's
  // done as part of Blynk-level setup, well before this point.
  timer.setInterval( 1 * 1000L, oneSecondTick);
  timer.setInterval( 5 * 1000L, clockTick5);
  timer.setInterval(60 * 1000L, clockTick60);
  Serial.print("Finished timer setup");
}

// --------------------------------------------------------------------------------------
// Access time from network NTP server, use to set system (OS) time. Called more than
// at setup because the local clock tick isn't perfectly accurate.Arduino documentation
// says 5 minutes is the outer limit before a warning flag is set. The calls are limited
// to reduce network traffic.
//
// Conversion to year 2000 epoch makes smaller offset variables possible.
//
void readTime() {
  auto unixSeconds = WiFi.getTime();
  y2kSeconds = unixSeconds - UNIX_OFFSET;
  set_system_time(y2kSeconds);
}

// --------------------------------------------------------------------------------------
// Does what Serial.println does, but accepts formats and variadic arguments
//
int serialPrintln(const char* format, ...) {
  char buf[200];
  va_list argptr;
  va_start(argptr, format);
  int nchars = vsnprintf(buf, sizeof(buf), format, argptr);
  va_end(argptr);
  Serial.println(buf);
  return nchars;
}

// --------------------------------------------------------------------------------------
//
void turnOffPump() {
  remainingPumpInterval = 0;
  updatePool(RELAY_OFF);
}

// --------------------------------------------------------------------------------------
//
void turnOnPump() {
  remainingPumpInterval = pumpDuration * DURATION_STEP;
  serialPrintln("Set pump duration to %d", remainingPumpInterval);
  updatePool(RELAY_ON);
}

// --------------------------------------------------------------------------------------
// Set a given pool state into the system
//
void updatePool(bool state) {
  poolState = state;
  digitalWrite(poolPin, poolState);
  writeSerialStatus("pump transition");
  if (isConnected()) {
    Blynk.virtualWrite(V1, poolState == RELAY_OFF ? 0 : 255);
  }
}

// --------------------------------------------------------------------------------------
// Write status to LCD display. Status written looks like:
//
//               111111                  111111
//     0123456789012345        0123456789012345
//    +----------------+      +----------------+
//    |Pump on 14:34:18|  or  |Pump -- 14:34:18|
//    |    07/20/20    |      |3w 4d 16h 55m 55|
//    +----------------+      +----------------+
//     0123456789012345        0123456789012345
//               111111                  111111
//
// That is, the pump status can be "on" or "--", while
// the second line can be either the date or the abbreviated
// uptime. The uptime string can be longer than fits,
// in which case it runs off the end.
//
// Holding the down button will turn off the backlight.
// That's probably more useful for test than anything
// else, as it becomes largely unreadable.
//
void writeLCDStatus() {
  // Backlight off if down button pushed
  bool down = haveButton(BUTTON_DOWN);
  lcd.setBacklight(down ? BACKLIGHT_OFF : BACKLIGHT_ON);

  lcd.setCursor(0, 0);
  lcd.print(formatLine0());
  lcd.setCursor(0, 1);
  lcd.print(formatLine1());

  writeVLCDStatus();
}

// --------------------------------------------------------------------------------------
// Output application status to Blynk Serial device.
//
// Recall that #define BLYNK_PRINT Serial at top of file will disable print.
// Not all the arithmetic has to be 32-bit (only minutes, but then back for multiply
// of hours back to minutes), but this is simpler to think about, occurs infrequently,
// and avoids a few conversions at the expense of a few larger divides & subtracts.
//
// Report looks like:
//
//    07/20/20 15:10:20 Pump on uptime = 2 weeks, 0 days, 5 hours, 20 minutes, 14 seconds
// or
//    07/20/20 15:10:20 Pump -- uptime = 13 weeks, 15 hours, 55 minutes
//
void writeSerialStatus(char* source) {
  serialPrintln("%s %s Pump %s | uptime = %s    <->    %s Pump duration %d Tick %d Buttons = %02x Connection = %d",
                formatTime(true, true),
                source,
                poolState == RELAY_ON ? "on" : "--",
                formatUptime(true),
                formatUptime(false),
                pumpDuration,
                remainingPumpInterval,
                lcd.readButtons(),
                isConnected());
}

// --------------------------------------------------------------------------------------
// Write status to virtual LCD display. Output is the same as hardware LCD (I hope)
//
void writeVLCDStatus() {
  if (!isConnected()) {
    return;
  }
  vlcd.print(0, 0, formatLine0());
  vlcd.print(0, 1, formatLine1());
}

// **************************************************************************************
// **************************************************************************************
// below here shouldn't have to change between applications
// **************************************************************************************
// **************************************************************************************
//

// --------------------------------------------------------------------------------------
// Application setup and boilerplate for major/minor cycles. Responsible for initializing
// the OS clock and time zone. DST is set periodically rather than here so that it
// actually changes as required. The clock is set from the NTP server.
//
void applicationSetup() {
  readTime();
  set_zone(-8 * ONE_HOUR);
  localSetup();
}

// --------------------------------------------------------------------------------------
// Call to implementation on 1 second cycle. Causes implementation routine to be invoked
// after first advancing OS clock and resetting DST status.
//
void oneSecondTick() {
  system_tick();
  clockTick();
  set_dst(usa_dst);
}

// **************************************************************************************
// Blynk / Wifi boilerplate

// --------------------------------------------------------------------------------------
// Interface to Blynk.connected(). It's here to provide a testing injection of mock
// disconnected WiFi. The WiFi will be forced to think it's disconnected as long as
// left button is down.
//
bool isConnected() {
  bool left = haveButton(BUTTON_LEFT);
  return !left && Blynk.connected();
}

// --------------------------------------------------------------------------------------
// Blynk callout for body of while true {}. Runs Blynk, timer, time update, and local loop
// tick. This depends on connection monitoring elsewhere (timer, see wifisetup and
// reconnectBlynk)
//
void loop() {
  if (isConnected()) {
    Blynk.run();
  }
  timer.run();

  y2kSeconds = time(NULL);
  loopTick();
}

// --------------------------------------------------------------------------------------
// Called on a timer. If the connection has gone down, this restores it. It's used
// as an alternative to the run loop blocking, to ensure the local display gets
// updated.
//
void reconnectBlynk() {
  if (!isConnected()) {
    Serial.println("Lost connection, attempting recommect");
    if (Blynk.connect()) {
      Serial.println("Reconnected");
    } else {
      Serial.println("Not reconnected");
    }
  }
}

// --------------------------------------------------------------------------------------
// Blynk callout for initialization. Sets serial port data rate, initializes WiFi, and
// calls for app setup.
//
void setup() {
  Serial.begin(9600);
  Serial.println("Beginning setup...");
  wifiSetup();
  applicationSetup();
  Serial.println("...setup complete");
}

// --------------------------------------------------------------------------------------
// Initialize Blynk WiFi link. Handles authentication.
//
void wifiSetup() {
  static const char auth[] = "#############";
  static const char ssid[] = "#############";
  static const char pass[] = "#############";
  Blynk.begin(auth, ssid, pass);
  while (Blynk.connect() == false) {  }           // Wait until connected
  timer.setInterval(15 * 1000L, reconnectBlynk);   // check every minute if still connected
}

Your snippets of code don’t really allow is to see enough of the picture to begin to understand what might be causing this.

However, using Blynk.begin and Blynk.connect together aren’t standard practice.
Normally, you’d use Blynk.config and Blynk.connect together, in place of the blocking Blynk.begin command in situations where you need the device to run regardless of whether or not it can make a connection to the WiFi network and the Blynk server.
I’m also not sure that the library used with the Uno/ESP-01 combo supports this connection method in the same way.

Is there a reason why you’ve gone for an Uno/ESO-01 rather than a NodeMCU or similar?

Pete.

I’ve posted the entire sketch above.

I suppose the reason I picked the one I did is that I’ve no prior Arduino experience combined with the fact I could readily stack the LCD and a shield with screw terminals so it seemed like a good choice for packaging the equipment.

When you said you were using an Uno, and connecting to WiFi, I expected you to be using an ESP-01 as your WiFi modem.
However, the library files you are using aren’t what I’d expect in that situation.
Can you explain in detail what your hardware setup is, and how the various components are wired and powered?

Pete.

My error for not posting the info in title in the body.

Arduino UNO WiFi Rev2. On that is a screw shield (Adafruit) for pinouts to the relays, and on top of that is an Adafruit 16x2 blue LCD shield with 5 buttons + reset. The pinouts on the shield are ground and D12.

Presently it’s powered from the USB port; in the long run it will be powered off a wall wart and the power jack.

The LCD is powered as part of the stack, pins coming through the screw shield.

It’s as simple as I could see.

RSSI gives me three bars out of 5, range being -60 to -100