Open Engineering
Environment and Development.

The program and supporting files CONFIG.TXT and UITXT.TXT for the Hive Data Logger are given below. This code has been working on my prototypes. However, this was developed as a hobby and hasn't undergone extensive testing - please bear in mind that there may be bugs and errors!

Program

/*
  LOW POWER DATALOGGER - Prototype 2 (with PCB & DS3231 RTC)
  For use with the datalogger that is equipped with SD/RTC, LCD, Weighing Scale, and power-down alarm from DS3231 RTC.

  DATE        VERSION  CHANGE
  07.10.2020  v4.3     Added calibration of temperature and weight sensors, with calibration values stored in EEPROM.
                       Removed temperature compensated weight calculation and column in LOG file as it is too hard to calibrate temperature
                       compensation coefficients, and possibly confusing for user. Changed weight to be in kg in LOG file instead of grams.
                       Added a retry mechanism for registration to GSM network if it doesn't register the first time.
                       Changed from SD to SdFat library (required just two lines - see include).
  22.11.2020  v4.3     Added FIXED_CALIBRATION build define, to use calibration fixed to Factory Defaults and remove the calibration menus for the Spanish build. 

 ***XXX      SETTING THE DATE AND TIME ON THE RTC: Now Possible via the UI (since v3.5). Alternatively see below:      XXX***
 ***XXX NOTE: SEE RTC SECTION FOR CODE TO COMMENT OUT AND THEN RE-COMMENT TO UPDATE TIME TO THAT OF CONNECTED PC       XXX***

                      ***XXX      NOTE: NOT ALL LCDs have same address SEE LiquidCrystal_I2C        XXX***


  =================================================================================================
  TO DO AND NICE-TO-HAVE LIST ('DONE' items first)
  =================================================================================================

  1) NICE-TO-HAVE: Introduce Seasonal Settings with a High Season Start and End (MM:DD for each). Also add High Season values for Interval,
  IntervalUnits and DaysPerSMS. During the High Season, the unit logs at the preseumably more frequent rate specified. If these additional
  parameters are absent or incomplete, High Season mode does not operate.

  2) NICE-TO-HAVE?: Put the CONFIG settings into EEPROM instead of storing in a structure. Saves RAM but maybe not needed at present. Might be easier
  if I want to allow user to modify settings from UI (potentially doing away with the SD CONFIG file altogether?).

  3) NICE-TO-HAVE: Learn how to connect to GPRS and transmit data to a website instead of using SMS. Or create an android app that reads and presents
  the SMS data as graphs or something!

 
  =================================================================================================
  PROGRAM OVERVIEW - Pseudocode
  =================================================================================================

  SET UP CODE
  ===========
  Set programERROR to NO_ERRORS (none)
  Determine Wake-Up reason (Manual, from battery connected or push button; or RTC alarm). Check alarmFired() to determine.
  IF Wake-Up reason was 'Manual' THEN set DoManualLog TRUE     (in order to take a single manual log)
  Initialise Serial, SD Card interface, Real Time Clock

  MAIN LOOP
  =========
  We can get here because of either: (a) the alarm went off and powered up the Nano; (b) The unit was just
  switched ON or the Push Button pressed, powering up the Nano; (c) The Nano is connected to a PC during test
  and therefore continuously powered.

  Read Battery Voltage
  Init & Clear LCD, and display Time, Battery Voltage, Number of Measurements Time of Next Measurement.

  Check that SD Card is OK and START and INTERVAL files are present. Set relevant programERROR if not.

  IF No programERROR (either from above or previously set) THEN proceed
  Read the START DateTime and the INTERVAL from respective files on SD

  IF alarmFired() THEN set DoAlarmLog TRUE   (note: if Nano is continuously powered from PC, the alarm may have gone off since last time around the loop)

  Work out when the alarm needs to go off to make the next set of measurements and set it.

  Make Measurements ...
  Read Temperatures T1 & T2 and Weight

  IF DoAlarmLog THEN
  Open ResultsDataFile for Write (creates it doesn't exist, e.g. if this is first measurement)
  If there's a file opening error, set programERROR = ERR_CANT_OPEN_LOG

  WRITE the NEW VALUES to next space in ResultsFile, with the scheduled DateTime of the measurement (it will be to nearest
  minute (Could think about adding exact DateTime if we wanted to nearest second).
  Close the ResultsDataFile

  IF SMS Due THEN      (Send SMS once every N Measurement Intervals.)
    Power UP GSM
    Compose and send SMS containing last (few?) measurements.
  ENDIF

  IF DoManualLog THEN
  Write to file as for Alarm log but with different reason given
  ENDIF

  IF WakeUpReason == Alarm THEN
  POWER DOWN (Clear the Power Control FlipFlop by toggling PIN_POWER_ARDUINO).
  ENDIF

******************************************************************************************
  We only get to here if it was a manual wake up (not alarm) or we are continuously powered

  Do any manual log requested here instead ???

  Display the Measurements UI
  IF No programERRORs
  Display how long until the NextLog, and current Temperature and Weight measurements on the LCD screen
  ELSE
  Display the ERROR number and Error details on the LCD screen
  ENDIF

  Delay several seconds to allow user to read a stable display before we loop back to start of main loop.

  ENDIF

  End of MAIN LOOP - this is the end of the program! However the loop will repeat if Nano is powered from PC

  ================================================================================================= */

// SOFTWARE BUILD SETTINGS
// =======================
#define FIXED_CALIBRATION 1  //  1 - to use fixed calibration set up in SW for the unit;   0 - to include the calibration adjustment UI screens.


// INCLUDE LIBRARIES
// =================
#include 
#include 

//#include       // Standard SD library
#include    // Better SD library (uses less ROM & RAM, SD card consumes less power).
SdFat SD;            // Better SD library
// Can use SdFat with programs written for SD.h with the two lines above. The new File class in SdFat supports all
// the SD.h File member functions plus the SdFile member functions. SdFat also supports the SD.h style open for
// the File class. NOTE: I Had problems getting this to work. I updated the SdFat library to 1.1.4 via 
// Tools->Manage Libraries in the IDE and then it all worked.

#include   // For saving calibration factor of Temperature Sensors

// Date and time functions using a DS3231 RTC connected via I2C and Wire lib
// See this reference: https://adafruit.github.io/RTClib/html/_r_t_clib_8h.html
#include   // This library is to communicate with I2C devices.
#include "RTClib.h"

// Use of LCD. See this reference: https://www.makerguides.com/character-i2c-lcd-arduino-tutorial/
#include 

#include   // Used to talk to GSM TC35 module.

// LCD DISPLAY - 16 chars 2 line display.  NOTE: SELECT (UNCOMMENT) THE ONE IN USE, as I have a mix of two I2C addresses.
// ======================================  ******************************************************************************
//LiquidCrystal_I2C lcd(0x3F, 16, 2); // set the LCD address to 0x3F for a 16 chars and 2 line display. The First one, unilluminated.
LiquidCrystal_I2C lcd(0x27, 16, 2); // set the LCD address to 0x27 for a 16 chars and 2 line display. The 2nd & 3rd ones, illuminated.

#define SCREEN_LENGTH 16
#define SCREEN_LINES 2
#define SCREEN_BUF_LEN SCREEN_LENGTH + 1  // Size of buffer needed for a line of screen text (add 1 for string terminator).

// EEPROM Address map
// ==================
#define TEMPERATURE_CALIB_T1_IN_EEPROM 0  // Location at which the CalibConstant for the LM335 measuring T1 is stored. It's an integer.
#define TEMPERATURE_CALIB_T2_IN_EEPROM TEMPERATURE_CALIB_T1_IN_EEPROM + sizeof(int)  // Location of CalibConstant for the LM335 measuring T2.
#define WEIGHT_ZEROFACTOR_IN_EEPROM TEMPERATURE_CALIB_T2_IN_EEPROM  + sizeof(int)    // Location at which the ZeroFactor for the HX711 and load cells is stored. It's a long.
#define WEIGHT_SCALEFACTOR_IN_EEPROM WEIGHT_ZEROFACTOR_IN_EEPROM + sizeof(long)      // Location at which the ScaleFactor for the HX711 and load cells is stored. It's a float.
#define NEXT_FREE_IN_EEPROM WEIGHT_SCALEFACTOR_IN_EEPROM + sizeof(long)             // Next free location - for future use!


#define TEXTS_START_IN_EEPROM 64  // Location from which UI texts are stored. Locations 0 - 63 are available for other data.

/* ============================ PIN Definitions ============================ */
// For SD Card
#define PIN_SD_CE    10   // LOW to select SD card
#define PIN_SD_MOSI  11   // SD: MOSI
#define PIN_SD_MISO  12   // SD: MISO
#define PIN_SD_CLK   13   // SD: CLK

// The I2C Bus pins are used for LCD Display and Real Time Clock (RTC). They don't need to be explicitly set up.
// A4 (SDA I2C) : Used by: LCD & RTC.   I2C Bus used by LCD, RTC and the Flash on RTC board.
// A5 (SCL I2C)   Used by: LCD & RTC.   I2C Bus used by LCD, RTC and the Flash on RTC board.

// For Siemens TC35 GSM Module
#define PIN_GSM_TX    7   // Tx Data
#define PIN_GSM_RX    8   // Rx Data
#define PIN_GSM_IGT   9   // IGT (ignition) line (press button). Set LOW to start TC35 and then HIGH or Input.


// For Temperature Sensors
#define PIN_TEMPERATURE1   0   // A0
#define PIN_TEMPERATURE2   1   // A1

// For Battery Voltage of PP3 and Push Button
#define PIN_BATLEVEL_BUTTON    6   // A6

// For Power Management
#define PIN_POWER_GSM 3        // High to turn ON TC35 GSM module.
#define PIN_POWER_ARDUINO 4    // Power: LOW to toggle FlipFlop to Arduino Powered OFF. [Set as Input or set High to leave Arduino powered]

#define LOG_MARGIN 20          // Margin in seconds from the current time in which the alarm will be not be set, but instead set
// one interval later. This is to avoid a log being lost when the user is looking at the User Interface,
// setting the clock, etc.

// SD Library uses short 8.3 names for files; max 8 characters.
const char CONFIG_FILE_NAME[] = "CONFIG.TXT";   // Specifies configuration settings.
const char DATA_FILE_NAME[]   = "LOG.TXT";      // Where the log data is written
const char ERROR_FILE_NAME[]  = "ERROR.TXT";    // Where AT Command dialogues and any error messages are written.
const char UITEXT_FILE_NAME[] = "UITXT.TXT";   // Specifies UI texts.


/* ============================  ERROR NUMBERS ============================ */
// Note: Need to keep the UI_ Text IDs to 16 chars or less.
#define NO_ERRORS            0
#define ERR_RTC_RESTART      1     // Text: UI_RTC_RESTART
#define ERR_NO_UITXTFILE     2     // Text: UI_NO_UITXTFILE
#define SW_UITEXT_MISMATCH   3     // Text: UI_SW_ID_MATCH    The number of Text IDs in EEPROM does not match the enum ui_text_IDs in this SW version.
#define ERR_CSV_READ_ERR     4     // Text: UI_CSV_READ_ERR
#define ERR_NOLOG_FOR_SMS    5     // Text: UI_NOLOG_FOR_SMS   When trying to send an SMS, the LOG file LOG.TXT did not appear to exists.

#define FIRST_FATAL_ERROR    6     // ERRORS from here on are treated as fatal.

#define ERR_NO_RTC           6     // Text: UI_NO_RTC
#define ERR_NO_SD_CARD       7     // Text: UI_NO_SD_CARD
#define ERR_NO_CONFIGFILE    8     // Text: UI_NO_CONFIGFILE
#define ERR_CANT_OPEN_LOG    9     // Text: UI_CANT_OPEN_LOG
#define ERR_FILE_2_SHORT    10     // Text: UI_FILE_2_SHORT
#define ERR_BAD_LOG_FILE    11     // Text: UI_BAD_LOG_FILE    The LOG file size is not a multiple of LOG_RECORD_LENGTH.
#define ERR_UNKNOWN_ERROR   12     // Text: UI_UNKNOWN_ERROR



/* ============================  GLOBAL VARIABLES ============================ */

byte programERROR; // Values higher than 0 (NO_ERRORS) indicate a program error was detected.

uint32_t CountOfAutoLogs; // The number of scheduled logs taken. Used to display Log Number.

boolean ManualWakeUp = false;   // Global, to be set true if it's a manual wakeup (not due to alarm firing).
boolean DoManualLog = false;    // Global, to be set true if it's a manual wakeup and we want a manual log.
boolean SayGoodbye = false;     // Global, to be set true after the full UI shown, so that Goodbye message shown on power down.

boolean WeighingInitialised = false; // Global, to be set true when the scale has been initialised. We only want to initialise when it's
// Needed as it takes time and thus power.

File fileHandle;  // Object of File class for using SD files. Note that only one file can be open at a time.

/* ============================ RTC Functions ============================
   Date and time functions using a DS1307 RTC connected via I2C and Wire lib
    DOCUMENTATION: https://adafruit.github.io/RTClib/html/
*/
RTC_DS3231 rtc;

void setupRTC () {
  if (! rtc.begin()) {
    //Serial.println(F("Err:No RTC")); // Couldn't find RTC
    programERROR = ERR_NO_RTC;
    //while (1);
  }

  if (rtc.lostPower()) {
    programERROR = ERR_RTC_RESTART;  // RTC was not running and had to be restarted. You expect this error just once after changing battery on RTC. If it occurs again,
    // it may be that the CR2032 Lithium battery on the board needs changing.
    //Serial.println(F("RTC NOT running: Restarted."));
    // following line sets the RTC to the date & time this sketch was compiled
    rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
    // OR this line sets the RTC with an explicit date & time, for example to set
    // December 20, 2019 at 8pm you would call:
    // rtc.adjust(DateTime(2019, 12, 20, 20, 0, 0));
  }
  else
  {
    //Serial.println(F("RTC already running")); //  So not resetting it's date time.
    // To force setting the date time, even if RTC is already running. TO UPDATE CLOCK WHEN CONNECTED TO PC: (1)UNCOMMENT LINE BELOW AND UPLOAD
    // PROGRAM, THEN (2) RE-COMMENT AND UPLOAD PROGRAM AGAIN.
    // rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));  // <-- UNCOMMENT this line to reset time to compilation time.
  }

  // TEST CODE: 
  DateTime now = rtc.now();
  //sprintf(cstr, "TimeYYYYMMDDHHMM:%04d:%02d:%02d:%02d:%02d", now.year(), now.month(), now.day(), now.hour(), now.minute());
  //Serial.println(cstr);

  //DateTime theDateTime(theLogYear, theLogMonth, theLogDay, theLogHour, theLogMinute, 0 );  //set the alarm time.
  //DateTime theDateTime(2020, 5, 10, 23, 55, 30 );  //set the alarm time.
  //Serial.println(F("Setting Alarm 1 ..."));  // This creates an alarm every time when seconds match
  //rtc.setAlarm1(theDateTime, DS3231_A1_Second);
}

/* ============================ SD Card Functions ============================
   DOCUMENTATION: https://www.arduino.cc/en/Reference/SD

   SD card read/write Section

   The circuit: SD card attached to SPI bus as follows:
     MOSI - pin 11
     MISO - pin 12
     CLK - pin 13
     CS -  pin 10 for Nano, (pin 4 for MKRZero SD: SDCARD_SS_PIN)
*/
bool setupSD() {
  // Open serial communications and wait for port to open:
  Serial.begin(9600);
  while (!Serial) {
    ; // wait for serial port to connect. Needed for native USB port only
  }

  if (!SD.begin(PIN_SD_CE)) {
    //Serial.println(F("InitSD failed!"));
    return false;
  }
  return true;
}

//------------------------------------------------------------------------------
// call back for file timestamps
void dateTime(uint16_t* date, uint16_t* time) {
  //char timestamp[30];
  DateTime now = rtc.now();
  //sprintf(timestamp, "%02d:%02d:%02d %2d/%2d/%2d \n", now.hour(),now.minute(),now.second(),now.month(),now.day(),now.year()-2000);
  //Serial.println(F("yy"));
  //Serial.println(timestamp);
  // return date using FAT_DATE macro to format fields
  *date = FAT_DATE(now.year(), now.month(), now.day());

  // return time using FAT_TIME macro to format fields
  *time = FAT_TIME(now.hour(), now.minute(), now.second());
}

/* ============================ Log Record Management Functions ============================
   Manage the handling of the log records.
*/

/*
   LOG RECORD STRUCTURE
   Keep fixed length to allow fast positioning in the file.
   PlannedLogTime  LogData
   --------------- -----------
   Year[4]-Month[2]-Day[2] Hour[2]:Minute[2],Weight[6],WeightCorrected[6],Temperature[5],Temperature[5],BatVolts[3],LogType[1],programERROR[1]/n   StringTermination  LOG_RECORD_LENGTH  52
   0       5        8      11      14        17        24                 31             37             43          47         49
   Year[4]-Month[2]-Day[2] Hour[2]:Minute[2],WeightKg[7],Temperature[5],Temperature[5],BatVolts[3],LogType[1],programERROR[2]/n   StringTermination  LOG_RECORD_LENGTH  47
   0       5        8      11      14        17          24             30             36          40         42                  45
*/

#define LOG_RECORD_YEAR_OFFSET  0
#define LOG_RECORD_MONTH_OFFSET    LOG_RECORD_YEAR_OFFSET    + 5    //  5
#define LOG_RECORD_DAY_OFFSET      LOG_RECORD_MONTH_OFFSET   + 3    //  8
#define LOG_RECORD_HOUR_OFFSET     LOG_RECORD_DAY_OFFSET     + 3    // 11
#define LOG_RECORD_MINUTE_OFFSET   LOG_RECORD_HOUR_OFFSET    + 3    // 14
#define LOG_RECORD_WEIGHT_OFFSET   LOG_RECORD_MINUTE_OFFSET  + 3    // 17
#define LOG_RECORD_TEMP1_OFFSET    LOG_RECORD_WEIGHT_OFFSET  + 8    // 25   // Have put weight in kg, taking 7 + comma
#define LOG_RECORD_TEMP2_OFFSET    LOG_RECORD_TEMP1_OFFSET   + 6    // 31
#define LOG_RECORD_VOLTS_OFFSET    LOG_RECORD_TEMP2_OFFSET   + 6    // 37
#define LOG_RECORD_TYPE_OFFSET     LOG_RECORD_VOLTS_OFFSET   + 4    // 41
#define LOG_PROG_ERROR_OFFSET      LOG_RECORD_TYPE_OFFSET    + 2    // 43
#define LOG_STR_TERMINATOR_OFFSET  LOG_PROG_ERROR_OFFSET     + 3    // 46  char + NewLine  
#define LOG_RECORD_LENGTH          LOG_STR_TERMINATOR_OFFSET + 1    // 47  See calculation above

#define LOG_RECORD_DATETIME_COL  0
#define LOG_RECORD_WEIGHT_COL  1
#define LOG_RECORD_TEMP1_COL  2
#define LOG_RECORD_TEMP2_COL  3

#define LOG_RECORD_DATETIME_COL_LEN  0
#define LOG_RECORD_WEIGHT_COL_LEN  1
#define LOG_RECORD_TEMP1_COL_LEN  2
#define LOG_RECORD_TEMP2_COL_LEN  3

/*
   Write a record field.

   sprintf USES:   %[flags][width][.precision][length]specifier
   We use this:  sprintf(recBuf, "%04d-%02d-%02d %02d:%02d,% 06ld,% 06ld,%s,%s,%01d.%01d,%c,%01d\0", Year, Month, Day, Hour, Minute, weight, weightTempCorrected, resStrT1, resStrT2, batVolts / 10, batVolts % 10, LogType, programERROR ); // Adds string_terminator too
   Sample result:
        2020-02-01 03:07,-99999,-99999,  7.1,-20.0,0.0,L,0
        12345678901234567890123456789012345678901234567890
        1        10        20        30        40        50

   NOTE: If the structure is changed, you MUST update the LOG_RECORD_xxxx_OFFSET defines above
     srcBuf : the char array containing the record
     Start  : the offset into the array where the digits start

   NOTE: Removed temperature compensated weight 'weightTempCorrected' in version 4.3.
*/
void writeRecordToFile (long weight, int Temperature1, int Temperature2, int batVolts, char LogType) {
  char recBuf[LOG_RECORD_LENGTH + 1];
  DateTime logTime = rtc.now();  // Choose the actual time, always

  char resStrT1[6];
  ConvertTx10ToDecimal (resStrT1, Temperature1);
  char resStrT2[6];
  ConvertTx10ToDecimal (resStrT2, Temperature2);

  char resStrW[8];
  ConvertWgToWkg (resStrW, weight);

  //  sprintf_P(recBuf,  PSTR("%04d-%02d-%02d %02d:%02d,% 06ld,%s,%s,%01d.%01d,%c,%01d\0"), logTime.year(), logTime.month(), logTime.day(), logTime.hour(), logTime.minute(), weight, resStrT1, resStrT2, batVolts / 10, batVolts % 10, LogType, programERROR ); // Adds string_terminator too
  // Put Weight in kg instead of g
  sprintf_P(recBuf,  PSTR("%04d-%02d-%02d %02d:%02d,%s,%s,%s,%01d.%01d,%c,%02d\0"), logTime.year(), logTime.month(), logTime.day(), logTime.hour(), logTime.minute(), resStrW, resStrT1, resStrT2, batVolts / 10, batVolts % 10, LogType, programERROR ); // Adds string_terminator too

  // The below adds the RTC measured temperature, but SW then fails as it needs 30828 bytes (100%) of program storage space. Maximum is 30720 bytes.
  //sprintf_P(recBuf,  PSTR("%04d-%02d-%02d %02d:%02d,%s,%s,%s,%03d,%01d.%01d,%c,%02d\0"), logTime.year(), logTime.month(), logTime.day(), logTime.hour(), logTime.minute(), resStrW, resStrT1, resStrT2, int(rtc.getTemperature()), batVolts / 10, batVolts % 10, LogType, programERROR ); // Adds string_terminator too
  // Serial.println(recBuf);
  fileHandle.println(recBuf);
}


/*
   Write the column headings to the CSV LOG file.

   NOTE: These MUST MATCH exactly the size of the LOG records.

        DateTime        ,Wt(g) ,T1(C),T2(C),Bat,M,Er
        2020-02-01 03:07,-99999,  7.1,-20.0,0.0,L,00
        12345678901234567890123456789012345678901234567890
        1        10        20        30        40        50
*/
void writeHeadingsToFile () {
  const static char ColHeadings[] PROGMEM = "DateTime        ,Wt(kg) ,Ex(C),In(C),Bat,M,Er";
  char myChar;
  for (byte k = 0; k < strlen_P(ColHeadings); k++) {
    myChar = pgm_read_byte_near(ColHeadings + k);
    fileHandle.write(myChar);
  }
  fileHandle.println();
}

/*
   Start or add to log.
   Create the log file if it doesn't already exist. Write a record to it.

   NOTE: Removed temperature compensated weight 'weightTempCorrected' in version 4.3.
*/
void LogDataToFile (long weight, int Temperature1, int Temperature2, int batVolts, char LogType) {
  // WRITE the NEW VALUES to next space in ResultsFile, with thisMeasureHour and thisMeasureMinute, plus calendar time stamp if desired.
  boolean WriteColumnHeadings = false;
  if (!SD.exists(DATA_FILE_NAME)) {
    WriteColumnHeadings = true;  // If the file is new.
    //Serial.println(F("Log !exist"));
  }
  fileHandle = SD.open(DATA_FILE_NAME, FILE_WRITE);
  if (fileHandle) {
    if (WriteColumnHeadings) {
      writeHeadingsToFile();
    }
    writeRecordToFile(weight, Temperature1, Temperature2, batVolts, LogType);
  } else {
    programERROR = ERR_CANT_OPEN_LOG;
    //Serial.println(F("Failed write to log file"));  // Often a symptom of too little dynamic memory dynamic memory, e.g. < 600.
  }
  fileHandle.close();
}

/* ============================ New CONFIG and LOG record CSV Reader functions ============================ */

#define MAX_TELNUM_LEN 16
#define MAX_HIVENAME_LEN 4

/* Default Start and End Date-Times. Modify the Start if required; the End need not be changed as it's arranged to be ten years later. */
#define UNIX_TIME_2020_2AM 1577887200  // Default start date-time in seconds. Corresponds to 2020-01-01 14:00
#define UNIX_TIME_10_YEARS  315619200  // 10 years in seconds, to be added to default start date-time to get a default end date-time.
// It includes 3 days for leap years to give 2030-01-01 14:00 (when added to 2020-01-01 14:00).


struct loggerConfig {
  uint32_t unixTimeSTART;
  uint32_t unixTimeEND;
  byte Interval;
  char IntervalUnits;
  byte DaysPerSMS;
  boolean SMS_On;
  char TelNum[MAX_TELNUM_LEN + 1];  // +1 for terminator
  char HiveName[MAX_HIVENAME_LEN + 1];
  char Language;  // E=English, S=Spanish, U=Unspecified so don't read UITXT and just use whatever is in EEPROM.
};

uint16_t gIntervalInMins;  // Globals calculated from above on reading config.
uint16_t gLogsPerSMS;
/*
   Structure object to hold Config settings. NOTE: This initialisation will be overwritten if there is an entry for the parameter in
   the CONFIG file, e.g. if row 'A' is present for start time, it's used (even if it's garbled or nonsense) but if row 'A' is absent,
   then the default is used.

   NOTE: REPLACE +44nnnnnnnnnn with your CountryCode and TelNumber!
*/
struct loggerConfig LogrCfg = {UNIX_TIME_2020_2AM, UNIX_TIME_2020_2AM + UNIX_TIME_10_YEARS, 12, 'H', 2, false, {"+44nnnnnnnnnn"}, {"C001"}, 'U'};



/*
   Reads text representing a decimal number with 1 decimal, returning the number times 10 as an int.

   Handles signed Decimals up to 2 digits, plus point, plus 1 decimal place, plus sign +/-     (Used for: T1(C),T2(C),Bat)
   Returns:   value x 10       as an Integer. E.g. if the value was 23.4, it returns 234.

   Parameter:
     valueText  - pointer to text to convert. IMPORTANT: Text is modified, see Version note below.
*/
/* Version 1: Best of the 3 in terms of ProgMem and RAM usage. BUT modifies the supplied string; use
   version 2 from sketch_CSV_Reader instead if this isn't OK. */
int getIntegerFromDecimalTxt(char *valueText) {
  int result = 0;
  char * pch;
  pch = strchr(valueText, '.');
  if (pch != NULL)
  {
    if (strlen(pch) < 2) {  // If no digits after point, e.g. "5."  instead of "5.0"
      *pch = '0';  // Add the '0'
    } else {
      *pch = *(pch + 1);
    }
    *(pch + 1) = 0; // Terminator
    result = atoi(valueText);
  } else {
    result = atoi(valueText) * 10;
  }
  return result;
}



/*
   Parses DateTime text in format YYYY-MM-DD HH:MM and returns uint32_t unixTime.
   Example date: 2020-08-01 12:30
   All 3 fields of date (YYYY-MM-DD) must be present.
   Time fields may be omitted in which case HH:MM is defaulted to 00:00
   Delimiters (and the space, if Time supplied) must be used as shown.

   Returns:
     uint32_t unixTime.
*/
uint32_t getDateTimeFromText(char *valueText) {
  int i = 0;
  char delim[] = "- :";
  uint16_t theLogYear = 2020;
  uint8_t theLogMonth = 1;
  uint8_t theLogDay = 1;
  uint8_t theLogHour = 0;
  uint8_t theLogMinute = 0;

  // Get the first token. Find '-' between YYYY and MM
  char * token = strtok(valueText, delim);
  /* walk through other tokens */
  while ( token != NULL ) {
    if (i == 0) {
      theLogYear = (unsigned int)(atoi(token));    // Case i==0 is the only unsigned int value (uint16_t).
    } else if (i < 5) {
      byte val = byte(atoi(token));  // All other cases except 0 are byte values (uint8_t).
      switch (i) {
        case 1:
          theLogMonth = val;
          break;
        case 2:
          theLogDay = val;
          break;
        case 3:
          theLogHour = val;
          break;
        case 4:
          theLogMinute = val;
          break;
      }
    }
    i++;
    token = strtok(NULL, delim);
  }
  DateTime theDateTime(theLogYear, theLogMonth, theLogDay, theLogHour, theLogMinute, 0 );  //set the read time.
  return theDateTime.unixtime();
}


/*
   This function is designed to retrieve fields from the CSV files LOG and CONFIG, doing the work common to:
     getColFromRecord() - which gets the text from a specified column in a specified record, in the LOG.TXT file.
                          IMPORTANT: This function can parse CSV rows of variable lengths, however getColFromRecord()
                          relies on all records in the file having the same length, i.e. LOG_RECORD_LENGTH, to locate
                          the position in the file where the requested record starts.

     getConfigData() - which retrieves data from CONFIG.TXT and has two modes:
                         a) Gets the text of specified column from the first record in the file whose first column
                            is a specified char letter. It is assumed only one record row will start with the required
                            char in column 0.
                         b) Gets the text from column 1 from all the records in the file whose first column
                            is a char, passing each value found and the corresponding char to a function which
                            populates the Config structure - updateConfigData(). In principle it could be used
                            similarly to retrieve a different column number from all records, e.g. some descriptive
                            text in col2 about the config value.
     getUiTexts()    - which retrieves UI texts from UITXT.TXT and writes them to the EEPROM (using 'update', so only
                       changes are written).
                       It gets the text from the specified column (2 for English, 3 for Spanish) from all the records
                       in the file, passing each value found to a function which updates EEPROM. Texts are separated
                       by a NULL byte (0), and the byte following the last text is set to 0xFF.

   PARAMETERS:
     Param - When called by getColFromRecord(), '?' is specified. It then only looks for the requested column in the
             the record at the current position in the file.
           - When called by getConfigData(), specify:
               For mode (a)- The char letter indicating the CONFIG record wanted (by matching the value in its first
                             field (col 0)). E.g. 'A'.
               For mode (b)- The char '*'.
            -When called by getUiTexts(), '#' is specified.
     ColNumWanted - the column containing the desired field, numbered from 0.
     destBuf - pointer to a char buffer (of sufficient length!) for the result, which will be:
                 The single LOG or CONFIG value requested.
                 Or, For getConfigData() mode (b): the chars actually found in column 0 (as a check that all CONFIG values were found).
                 Or, For getUiTexts() NULL as it is not used.

   SPEED WHEN READING UITXT:
     Testing with the 48 current text strings I found:
         getConfigDataOrCol() reads the file and updates EEPROM (mostly it should not have to write) in 34mS (0.034s).
         Testing with the full CSV including enums, English & Spanish, it takes 80mS instead of 34mS.

   RETURNS:
     true   - if it found the requested field, or found all the required CONFIG data.
*/
boolean getConfigDataOrCol(char Param, byte ColNumWanted, char *destBuf) {
  //unsigned long startTime = millis();
  // ASSUME FILE OPEN AT REQUIRED POSITION
  size_t n;      // Length of returned field with delimiter.
  char str[20];  // Must hold longest field with delimiter and zero byte.
  byte column = 0;
  byte nextColumn;
  char Col0Val = 0;
  boolean ParamFound = false;
  destBuf[0] = 0; // Terminate in case no result
  int nextEEPROMaddress = TEXTS_START_IN_EEPROM;
  byte NumOfTextRows = 0;
  // Read the file and print fields.
  while (column < 100) {    // Limit to 99 columns!
    n = readField(&fileHandle, str, sizeof(str), ",\n");
    // done if Error or at EOF.
    if (n == 0) break;

    // Remove the delimiter, and work out next colNum.
    if (str[n - 1] == ',' || str[n - 1] == '\n') {
      if (str[n - 1] == '\n') {
        nextColumn = 0;
        NumOfTextRows++;
      } else {
        nextColumn = column + 1;
      }
      // Remove the delimiter, adding terminator instead.
      str[--n] = 0;
      // Strip trailing whitespace
      while (n > 0  && str[n - 1] == ' ') {
        str[--n] = '\0';
      }
    } else {
      // At eof, too long, or read error.  Too long is error.
      //Serial.print(fileHandle.available() ? F("CSV read err") : F("eof"));
      programERROR = ERR_CSV_READ_ERR;
    }

    // Remember the char in col 0
    if (column == 0 && n == 1) {  // We found a char in col 0
      Col0Val =  str[0];
      if (Param != '?' && Param != '*' && Param != '#' && str[0] == Param) {
        // Param is '*' for 'All' or We found the Param in first column. So if we find the desired colNum in this record, it's the value.
        ParamFound = true;
      }
    }

    if (column == ColNumWanted) {
      if (Param == '?' || ParamFound) {
        // Return this value. Record action and ParamFound action.
        strcpy(destBuf, str);
        return true;  // The Value was found.
      } else if (Param == '*') {
        // Call Update action
        updateConfigData(Col0Val, str, destBuf);
      } else if (Param == '#') {
        // Call Store text in EEPROM
        nextEEPROMaddress = updateEEPROM(nextEEPROMaddress, str);
      }
    }

    if (nextColumn == 0) {
      if (Param == '?') {
        break; // We are in LogRecord mode and we reached end of record. So finish.
      }
      Col0Val = 0;  // Clear the last column zero value, ready for next row.
    }
    column = nextColumn;
  }
  if (Param == '#') {
    EEPROM.update(nextEEPROMaddress, 0xFF);
    /*
      unsigned long endTime = millis();
      Serial.println();
      Serial.print(F("Last used EEPROM address = "));
      Serial.println(nextEEPROMaddress);
      Serial.print(F("Length of Text data = "));
      Serial.println(nextEEPROMaddress - TEXTS_START_IN_EEPROM);
      Serial.print(F("Number of Rows of Text data = "));
      Serial.println(NumOfTextRows);
      Serial.print(F("File Read Time: "));
      Serial.println(millis() - startTime); //prints time since function
      Serial.print(F("File Read Time no serial: "));
      Serial.println(endTime - startTime); //prints time since function
    */
  }
  return false; // Value not found, or it was an update all config case.
}


/*
   Gets data from UITXT.TXT and update changes to EEPROM.

   Retrieves UI texts from UITXT.TXT and writes them to the EEPROM (using 'update', so only changes are written).
   It gets the text from the specified column (2 for English, 3 for Spanish) from all the records
   in the file, passing each value found to a function which updates EEPROM. Texts are separated
   by a NULL byte (0), and the byte following the last text is set to 0xFF.

   PARAMETERS:
     ColNumWanted - the column containing the desired field, numbered from 0.
*/
void getUiTexts(byte ColNumWanted) {
  if (!SD.exists(UITEXT_FILE_NAME)) {
    //Serial.println(F("Err: No UITEXT file"));  // We can hope the UI texts have been previously read into EEPROM, so may not be a fatal error.
    programERROR = ERR_NO_UITXTFILE;
  } else {
    fileHandle = SD.open(UITEXT_FILE_NAME);
    getConfigDataOrCol('#', ColNumWanted, NULL);
    fileHandle.close();
  }
}


/*
   Updates the EEPROM with a text read from UITXT.TXT. Returns the EEPROM address to use for the next text.

   Data is written to the EEPROM using 'update', so only changes are written.
   Texts are followed by a NULL byte (0) as a seperator.

   PARAMETERS:
     nextEEPROMaddress - the EEPROM address to update.
     str - pointer to the string (char array) to be written

   RETURNS:
     Updated nextEEPROMaddress.
*/
int updateEEPROM(int nextEEPROMaddress, char *str) {
  for (int i = 0; i < (strlen(str)); i++) {
    if (str[i] != '"' || (i > 0 && i < (strlen(str) - 1) ) ) { // Knock off first and last characters if they are a ".
      EEPROM.update(nextEEPROMaddress, str[i]);
      nextEEPROMaddress++;
    }
  }
  EEPROM.update(nextEEPROMaddress++, 0);  // Write NULL terminator and separator.
  return nextEEPROMaddress;
}

/*
   Gets the text from a specified column in a specified record, in the LOG.TXT file.
   IMPORTANT: It relies on all records in the file having the same length, i.e. LOG_RECORD_LENGTH.
              This is because it locates the position in the file where the record starts by calculating
              RecordNumber x LOG_RECORD_LENGTH   and doing a seek to that position before
              calling getConfigDataOrCol() to parse the record found there.

   PARAMETERS:
     RecordNumber - the record containing the wanted field, numbered from 0. BUT NOTE: Record 0 is the Column Titles Row!!
     ColNum - the column containing the field, numbered from 0.
     destBuf - pointer to a char buffer (of sufficient length!) where the retrieved field is to be written.
     destBuf_LEN - The length of the char buffer so that we can set it to string terminators and an error message (in case record is not found).
     fileHandle - GLOBAL which must be currently the handle for the open DATA_FILE_NAME.

   RETURNS: true   - if it found the field. N.B. Max columns is 99; I chose to limit to this in getConfigDataOrCol().
            false  - if column not found or the file is too short to contain the requested record.
                     Sets Error 3 if file too short.

*/

boolean getColFromRecord(unsigned long RecordNumber, byte ColNum, char *destBuf, size_t destBuf_LEN ) {
  memset(destBuf, '\0', destBuf_LEN); // Fill with string terminator.
  boolean result = true;
  unsigned long filePosition = RecordNumber * (unsigned long)(LOG_RECORD_LENGTH);
  if ( (filePosition + LOG_RECORD_LENGTH) > fileHandle.size() ) // Check the file is big enough to have the desired record
  {
    programERROR = ERR_FILE_2_SHORT;
    //Serial.println(int(RecordNumber));
    //Serial.println(int(filePosition));
    //Serial.println(int(fileHandle.size()));
    result = false;
  } else if ( fileHandle.size() % (unsigned long)(LOG_RECORD_LENGTH) )  // Check the file size is a multiple of LOG_RECORD_LENGTH. If it isn't we cannot locate a
  { // record. Likely reason is a LOG file on the SD written by a different version of SW with a
    programERROR = ERR_BAD_LOG_FILE;                             // different LOG_RECORD_LENGTH (solution: delete old LOG file). Or possibly LOG file is corrupted?
    //Serial.println(F("Err: BadLog"));
    result = false;
  }
  if (result  == false) {             // If either of above errors occurs, return the error number as the field.
    destBuf[0] = 'E';
    if (destBuf_LEN > 3) {
      itoa(int(programERROR), destBuf + 1, 10); // Provided buffer is big enough, write error message.
    }
  } else {
    fileHandle.seek(filePosition);
    result = getConfigDataOrCol('?', ColNum, destBuf);
  }
  return result;
}


/*
   Gets data from CONFIG.TXT and has two modes:
     a) Gets the text in column 1 from the first record in the file whose column 0 contains the specified
        char letter A-Z. It is assumed only one record row will start have that char in column 0. This can
        be used to retrieve a single CONFIG value.
     b) Gets the text from column 1 from all the records in the file whose first column is a char, passing each
        value found and the corresponding char to function updateConfigData() which updates the field matching
        the char in the Config structure. This is used to read the CONFIG settings following power up.

   PARAMETERS:
     Param - For Mode (a) the char letter specifying the record wanted by the value in its first field (col 0). E.g. 'A'.
           - For Mode (b) the char '*'.
     destBuf - pointer to a char buffer (of sufficient length!) for the result, which will be:
                 For Mode (a): The single CONFIG value requested.
                 For Mode (b): the chars actually found in column 1 (as a check that all CONFIG values were found).

   RETURNS: true   - if it found the field (in mode a), or the CONFIG data (in mode b).
            false  - if field not found  (in mode a).
*/
boolean getConfigData(char Param, char *destBuf) {
  boolean result;
  //Serial.println(F("Getting CONFIG stuff"));
  fileHandle = SD.open(CONFIG_FILE_NAME);
  result = getConfigDataOrCol(Param, 1, destBuf);
  fileHandle.close();
  constrainConfigDataAndSetGlobals();
  return result;
}

/*
   Constrain the CONFIG data to be usable values (modify them if not).
   Calculate and set the globals:
     gIntervalInMins
     gLogsPerSMS

   Constraints:
     IntervalUnits  - must be one of D,H,M. If not, set to H.
     Interval  - must be divisible into next unit up, i.e. if in minutes, into 60; if in hours, into 24. If not, adjusted to be so.
     SMS_On   -  Must be 'false' (= SMS OFF) if gLogsPerSMS is 0.
   The values for the globals are calculated after the constraints have been applied.

*/
void constrainConfigDataAndSetGlobals(void) {
  // Apply constraints:
  if ( strchr("DHM", LogrCfg.IntervalUnits) == NULL ) {
    LogrCfg.IntervalUnits = 'H'; // Default to H if not one of DHM.
  }
  if ( LogrCfg.IntervalUnits == 'M') {
    LogrCfg.Interval = ConstrainInterval(60, LogrCfg.Interval);
    gIntervalInMins = (uint16_t)(LogrCfg.Interval);
  } else if ( LogrCfg.IntervalUnits == 'H') {
    LogrCfg.Interval = ConstrainInterval(24, LogrCfg.Interval);
    gIntervalInMins = ((uint16_t)(LogrCfg.Interval)) * 60;      // Convert Hours to Minutes
  } else {
    gIntervalInMins = ((uint16_t)(LogrCfg.Interval)) * 60 * 24; // Convert Days to Minutes
  }

  // DaysPerSMS : An SMS is sent every DaysPerSMS days or Interval, whichever is less frequent. Note:  0 is interpreted as OFF  No SMS will be sent. */
  if ( LogrCfg.DaysPerSMS == 0) {
    LogrCfg.SMS_On = false;  // Ensure SMS is OFF
  } else if ( LogrCfg.IntervalUnits == 'D' && LogrCfg.Interval > LogrCfg.DaysPerSMS) {
    LogrCfg.DaysPerSMS = LogrCfg.Interval;
  }
  gLogsPerSMS = (int(LogrCfg.DaysPerSMS) * 60 * 24) /  gIntervalInMins;  // We have ensured above that we have at least 1 log per SMS.
  // Serial.println(F("gLogsPerSMS, DaysPerSMS, gIntervalInMins, Interval:")));
  // Serial.println(gLogsPerSMS);
  // Serial.println(int(LogrCfg.DaysPerSMS));
  // Serial.println(gIntervalInMins);
  // Serial.println(int(LogrCfg.Interval));
}

/*
   Called to update each config setting with the value read from the CONFIG file field.

   This is the current plan for the CONFIG data. This function will need modifying if additional
   CONFIG parameters are added or exixting ones changed.

   PLAN:
   Use one row per parameter. Optionally could add an additional name column (or two cols, one
   for English, one for Spanish). See examples below. For the moment the name colums are IGNORED
   and it won't matter if one or both are absent; not having them at all would make it easier for
   the unit to write the file, if we crate a UI that allows the user to change settings.

   Examples:

   Example 1) With 2 name columns
     PARAM, VALUE, NAME, NOMBRE
     A, 2020-05-25 11:00,Start,Inicio
     B, 2020-09-12 18:00,End,Fin
     C, 6,Every,Cada
     D, H,D_H_or_M, D_H_o_M
     E, 2,DaysPerSMS,DiasPorSMS
     F, Y,SMS_Yes_No,SMS_Si_No
     G, +4499999999999,TelNum,TelNum
     H, C001,Hive,Colmena
     I, S,Lang,Idioma

   Examples 2&3) With 1 name column only in one language
     PARAM, VALUE, NAME
     or
     PARAM, VALOR, NOMBRE

   DATA TYPES PRESENT IN LOG AND CONFIG FILES
     Text strings up to 16 [longest mobile with + and country code]   (Used for all column titles, and:  TelNum, Hive)
     Single chars                                                     (Used for: LogMode[L or M], D_H_or_M)
     DateTime up to 16 assuming no extra spaces.
       2020-08-01 12:30     - DateTime as YYYY-MM-DD HH:MM  Should Time be optional?      (Used for: Start, End)
     Unsigned Integers up to 2 digits                  (Used for: ErrorNum, Every, DaysPerSMS)
     Signed Integers up to 5 digits plus sign +/-      (Used for: Start, End)
     Signed Decimals up to 2 digits, plus point, plus 1 decimal place, plus sign +/-     (Used for: T1(C),T2(C),Bat)
                                             Could return as x10 as an Integer

     CONSTRAINTS These constraints are applied to the values:
     C Interval : If specified (in D, IntervalUnits) in minutes, range 1-30, and there must be a whole number of log intervals in an hour
                  If specified in hours, range 1-12 and there must be a whole number of log intervals in a day.
                  If specified in days, range 1-upwards.

     D DaysPerSMS : An SMS is sent every DaysPerSMS days or Interval, whichever is less frequent. Note:  0=OFF  No SMS will be sent.
                    The time of day the SMS is sent is the START_TIME.
                    E.g. If Interval= 6 HOURS, and DaysPerSMS=2, THEN and SMS is sent every 2 days.
                         If Interval= 3 DAYS, and DaysPerSMS=2, THEN and SMS is sent every 3 days.


*/
void updateConfigData(char Col0Val, char *str,  char *destBuf) {
  //Serial.print(F("Update Parameter "));
  char Prm[2];
  Prm[0] = Col0Val;
  Prm[1] = 0;
  //Serial.print (Prm);
  //Serial.print (F(" with value: "));
  //Serial.println(str);
  int lenStr = strlen(destBuf);
  destBuf[lenStr] = Col0Val; // record parameters seen
  destBuf[lenStr + 1] = 0; // move terminator

  if (Col0Val == 'A') { // A, 2020-05-25 11:00,Start,Inicio
    LogrCfg.unixTimeSTART = getDateTimeFromText(str);
  } else if (Col0Val == 'B') { // B, 2020-09-12 18:00,End,Fin
    LogrCfg.unixTimeEND = getDateTimeFromText(str);
  } else if (Col0Val == 'C') { // C, 6,Every,Cada
    LogrCfg.Interval = byte(atoi(str));
  } else if (Col0Val == 'D') { // D, H,D_H_or_M, D_H_o_M
    LogrCfg.IntervalUnits = (char)(toupper(int(str[0])));
  } else if (Col0Val == 'E') { // E, 2,DaysPerSMS,DiasPorSMS
    LogrCfg.DaysPerSMS = byte(atoi(str));
  } else if (Col0Val == 'F') { // F, Y,SMS_Yes_No,SMS_Si_No
    LogrCfg.SMS_On = ( strchr("YySs", str[0]) != NULL );  // Accept Yes or Si in upper or lower case, so first char must be Y, y, S or s
  } else if (Col0Val == 'G') { // G, +4499999999999,TelNum,TelNum
    if (strlen(str) <= MAX_TELNUM_LEN) {
      strcpy(LogrCfg.TelNum, str);
    }
  } else if (Col0Val == 'H') { // H, C001,Hive,Colmena
    if (strlen(str) <= MAX_HIVENAME_LEN) {
      strcpy(LogrCfg.HiveName, str);
    }
  } else if (Col0Val == 'I') {  // I, S,Lang,Idioma. E=English, S=Spanish, U=Unspecified so don't read UITXT and just use whatever is in EEPROM.
    LogrCfg.Language = str[0];
  }
}

/*
   Used to constrain the Logging Interval value when specified in MINUTES or HOURS to sensible values that are divisible into hours and
   days. Thus when the Logging Interval units specified (in CONFIG row D, 'IntervalUnits') are:
        In Minutes 'M', THEN the permitted range of the Log Interval is 1-30, and there must be a whole number of log intervals in an hour.
        In Hours 'H', THEN the permitted range of the Log Interval is 1-12 and there must be a whole number of log intervals in a day.

   Parameters:
     DivisibleInto - So put 60 if the IntervalUnits are Minutes, 24 if the IntervalUnits are Hours.
     Val - The Interval value to be constrained

   Returns:
     The constrained Interval value.
*/
byte ConstrainInterval(byte DivisibleInto, byte Val) {
  if (Val == 0) {
    Val = 1;                                  // Ensure Val is not 0
  } else if ( Val >= DivisibleInto / 2) {
    Val = DivisibleInto / 2;                  // Can never be more than half DivisibleInto, e.g. 30 minutes if unit is minutes.
  } else {
    while (DivisibleInto % Val) {
      Val++; // Increment until it divides in exactly.
    }
  }
  return Val;
}

/* ============================ CSV READER ============================*/


/*
   Function to read a CSV file  one field at a time.
   Modified from:
     CSV READER
     DOCUMENTATION:https://forum.arduino.cc/index.php?topic=340849.0
     My modifications are to strip leading whitespace and if present, a leading '"' .

   Copes with MSDOS and LINUX line endings:
     DOS uses carriage return and line feed ("\r\n") as a line ending, while Unix uses just line feed ("\n").
     You need to be careful about transferring files between Windows machines and Unix machines to make sure
     the line endings are translated properly.

   PARAMETERS
     file - File to read.
     str - Character array for the field. Must be big enough for largest field.
     size - Size of str array.
     delim - String containing field delimiters.

   RETURNS
     return - length of field including terminating delimiter, but excluding leading or trailing whitespace.
     Writes field text to str.
       Note: The last character of str will not be a delimiter if a read error occurs, the field is too long,
       or the file does not end with a delimiter. Consider this an error if not at end-of-file.
*/
size_t readField(File * file, char* str, size_t size, char* delim) {
  char ch;
  boolean textStarted = false;
  size_t n = 0;
  while ((n + 1) < size && file->read(&ch, 1) == 1) {
    // Delete CR.
    if (ch == '\r') {
      continue;
    }
    if (ch == ' ' && !textStarted) {
      continue;
    }
    if (ch == '"' && !textStarted) {
      textStarted = true;               // Skip first " but start recording all text after that including spaces.
      continue;
    }
    textStarted = true;
    str[n++] = ch;
    if (strchr(delim, ch)) {
      break;
    }
  }
  str[n] = '\0';
  return n;
}

/*
  ERRORS SEEN and IDEAS
  =====================
  ERRORS:
  ======

  IDEAS:
  ======
  uint32_t  0 to 4,294,967,295    Equates to 136 years (86 left from 2020) or 71582788.25 minutes
   DateTime (const DateTime ©)
   DateTime (uint32_t t=SECONDS_FROM_1970_TO_2000)
   uint32_t  unixtime (void) const     //  Return unix time, seconds since Jan 1, 1970.

   a 'ul' or 'UL' to force the constant into an unsigned long constant. Example: 32767ul
   long  Long variables are extended size variables for number storage, and store 32 bits (4 bytes), from -2,147,483,648 to 2,147,483,647.
*/


/* ============================ Wake Up Time Calculation Functions ============================ */
/*
   Work out the time for next measurement.
   ---------------------------------------
   We pass in the DateTime logging will be or was started, and the interval (in seconds) that we are using.
   The maximum interval I'm planning to use is day(s) and the minimum is 2 minutes.

   Parameters:
     IntervalInSeconds - The time between measurements in seconds. NOTE: The Log INTERVAL must be > TWICE this, to avoid multiple loggings.
     StartLogTime - The time logging started, as read from START file, and converted to uTime.

   Returns:
     uTimeOfNextMeasurement.  We use this to set the next alarm.
     Sets global CountOfAutoLogs.
*/
uint32_t getTimeOfNextMeasurement (uint32_t IntervalInSeconds, uint32_t uTimeOfStartLogTime ) {  // Use the defines above.
  uint32_t uTimeOfNextLogTime;
  uint32_t LogMargin = (unsigned long)(LOG_MARGIN);

  DateTime now = rtc.now();
  CountOfAutoLogs = 0;
  if (uTimeOfStartLogTime > (now.unixtime() + LogMargin) ) {  // We need the margin to allow time for alarm to fire if it's about to go off.
    // The start time is in the future, so set the alarm time to be the start time.
    uTimeOfNextLogTime = uTimeOfStartLogTime;
  } else {
    CountOfAutoLogs = ( now.unixtime() + LogMargin - uTimeOfStartLogTime) / IntervalInSeconds + 1L; // Gets the number of scheduled logs done, including this one.
    //Serial.print(F("AutoLogs="));
    //Serial.print(CountOfAutoLogs);
    //Serial.print(F("  IntervalSecs="));
    //Serial.println(IntervalInSeconds);
    uTimeOfNextLogTime = uTimeOfStartLogTime + (CountOfAutoLogs * IntervalInSeconds); // Gets the uTime of the next scheduled logs. We set the next alarm to this.
  }
  return uTimeOfNextLogTime;
}


/* ============================ LCD Display Functions ============================ */
void LcdSetup()
{
  lcd.init();                      // initialize the lcd
  lcd.backlight();
  lcd.clear();
  lcd.setCursor(0, 0);
}

void LcdNewScreen(void)
{
  lcd.setCursor(0, 0);
  lcd.clear();
  WaitForPushButtonRelease(); // TO Guarantee button hasn't remained pressed since last screen, when we enter the new screen.
}

void LcdString(char *characters)
{
  lcd.print(characters);
}


/* ============================ LCD Screen Write Functions ============================ */

/*
   Convert an integer Temperature value stored as x10 (i.e. -95 == -9.5) to text format with
   sign and decimal point.

   Parameters:
     T : range 999 to -999
     resStr : char buffer for result string, min 6 long.

   Returns:
     Formatted string in resStr, between 99.9 and -99.9

*/
void ConvertTx10ToDecimal (char resStr[], int T) {

  sprintf_P(resStr, PSTR("%3d.%01d\0"), T / 10, abs(T % 10)); // Adds string_terminator too

  if ( (T < 0) && (T / 10 == 0) ) {
    resStr[1] = '-';
  }
}

/*
   Convert an integer Weight value stored as grams to kg text format with
   sign and decimal point.

   Parameters:
     W : range 99999 to -99999
     resStr : char buffer for result string, min 8 long.

   Returns:
     Formatted string in resStr, between 99.999 and -99.999

*/
void ConvertWgToWkg (char resStr[], long W) {
  sprintf_P(resStr, PSTR("%3d.%03d\0"), int(W / 1000L), abs(int(W % 1000L)) ); // Adds string_terminator too

  if ( (W < 0L) && (W / 1000L == 0) ) {
    resStr[1] = '-';
  }
}


/*
   Finds the start address in EEPROM for the text corresponding to a UI Text ID.

   Works by reading the EEPROM, counting up the '0' terminators that separate texts until
   the one matching ID is reached. Exits with 'not found' if an 0xFF is found or reaches end of EEPROM.

   PARAMETERS:
     textIdWanted - Text IDs are in an enum, that must match the order of the texts
                    loaded from SD UITXT file into EEPROM.
     LenOfFound - pointer to a byte, to allow function to return the length of the found text.

   RETURNS:
     - The EEPROM address of the text (TEXTS_START_IN_EEPROM - 1022), or -1 if not found. Note: Not Found would
       only occur if the value of textIdWanted was greater than the number of
       texts in the EEPROM
     - The length of the found text, via LenOfFound pointer

   SPEED:
     Testing with the 48 current text strings, I found that FindText() locates the string in a max of 1536uS (1.5mS)
     for the last string, and much less for the earlier strings.
*/
int FindText(byte textIdWanted, byte* LenOfFound)
{
  byte thisChar;
  int StartOfFound = TEXTS_START_IN_EEPROM;
  *LenOfFound = 0;
  byte textIdFound = 0;
  for (int i = TEXTS_START_IN_EEPROM; i < 1023; i++) {
    thisChar = EEPROM.read(i);
    if (thisChar == 0) {
      if (textIdFound == textIdWanted) {
        return StartOfFound;
      }
      textIdFound++;
      StartOfFound = i + 1;
      *LenOfFound = 0;
    } else if (thisChar == 0xFF) {
      break; // End of texts reached without finding the wanted one.
    } else {
      (*LenOfFound)++;
    }
  }
  return -1;  // Means ERROR - NOT FOUND.
}


/*
    Get or Display the Text identified by the textID, on the specified line, with the specified justification.

   The caller can choose to either:
       a) Copy the text into a char[] buffer of sufficient size for a screen line.
       b) Display the txt on the LCD.

   In both cases, the formatting is applied.

   Parameters:
     - TextIdx - ID of ProgMem text.
     - Where - 0xJL  in which:
         L=Line of screen to write to, 0=Line 0, 1=Line 1, 2=Line 0 underlined on Line 1 (e.g.by stars ***);
         J=Justification, 0=LH, 1=RH, 2=Centred.
            Example: 0x20 = Centred, on Line 0.
     - ScreenBuf - If not NULL, then function writes to the supplied buffer instead of LCD.
                   Note that in this case: The length of the supplied buffer must be at least SCREEN_BUF_LEN.
                                           Justification is applied but Line number is ignored.

   SPEED:
     Testing with the 48 current text strings I found:
     FindText() locates the string in a max of 1536uS (1.5mS) for the last string, and much less for the earlier strings.
     The whole of DisplayUiText when writing to a char BUFFER takes 50 to 1500 uS (0.05 - 1.5 mS) depending on the text.
     The whole of DisplayUiText when writing to LCD takes 10 to 50 mS depending on the text. So almost all time is writing to LCD.

   RETURNS: True if textID found, False if not.
*/
#define TXT_LH 0
#define TXT_RH 16
#define TXT_CENTRED 32
#define TXT_LINE0 0
#define TXT_LINE1 1
#define TXT_UNDERLINED 2

boolean GetOrDisplayUiText(byte TextIdx, byte Where, char* ScreenBuf  )
{
  char charRead;
  byte LenOfFound;
  int address = FindText(TextIdx, &LenOfFound);
  if (address < 0) {
    return false;
  }

  byte txtOffset = 0;
  if ((0xF0 & Where) == TXT_RH) {
    txtOffset = SCREEN_LENGTH - LenOfFound;
  } else if ((0xF0 & Where) == TXT_CENTRED) {
    txtOffset = (SCREEN_LENGTH - LenOfFound ) / 2;
  }
  if (ScreenBuf == NULL) {
    lcd.setCursor(txtOffset, 0x01 & Where);
  }

  for (byte i = 0; i < LenOfFound; i++)
  {
    charRead = EEPROM.read(address++);
    if (ScreenBuf == NULL) {
      lcd.write(charRead);
    } else {
      ScreenBuf[i + txtOffset] = charRead;
    }
    //Serial.write(charRead);
  }
  //Serial.println("");
  if ( (ScreenBuf == NULL) && ((0x0F & Where) == TXT_UNDERLINED) ) {
    lcd.setCursor(txtOffset, 1);
    for (byte i = 0; i < LenOfFound; i++) {
      lcd.write('*');
    }
  }
  return true;
}

/*
 * LineNo = 0 for 1st line, 1 for 2nd line
 */
void ClearLcdLine(byte LineNo)
{
  lcd.setCursor(0, LineNo);
  for (byte i = 0; i < SCREEN_LENGTH; i++) {
    lcd.write(' ');
  }
}


/*
   Displays the Text identified by the textID, on the specified line, with the specified justification.
   Used to call GetOrDisplayUiText() as most often, the 'get to buffer' option is not needed.
   See GetOrDisplayUiText() for detailed description.

   PARAMETERS:
     - TextIdx - ID of ProgMem text.
     - Where - 0xJL  in which:
         L=Line of screen to write to, 0=Line 0, 1=Line 1, 2=Line 0 underlined on Line 1 (e.g.by stars ***);
         J=Justification, 0=LH, 1=RH, 2=Centred.
            Example: 0x20 = Centred, on Line 0.
       You can use masks for the Where parameter (note the bitwise OR), e.g.:
         TXT_LH
         TXT_RH
         TXT_CENTRED
         TXT_RH | TXT_UNDERLINED
         TXT_CENTRED | TXT_UNDERLINED
         TXT_LH | TXT_LINE1,

   RETURNS: True if textID found, False if not.
*/
boolean DisplayUiText(byte TextIdx, byte Where)
{
  return GetOrDisplayUiText(TextIdx, Where, NULL);
}


/* Display Banner Screen Centered
   Clears screen and shows 2 lines of text, centred on the screen, for 2 seconds.

   Parameters:
     ID of text for line 0
     ID of text for line 1
*/
void DisplayBannerScreenCentred(byte Line0TxtID, byte Line1TxtID)
{
  LcdNewScreen();
  DisplayUiText(Line0TxtID, TXT_CENTRED);
  DisplayUiText(Line1TxtID, TXT_CENTRED | TXT_LINE1);
  delay(2000);
}

/* Display Section Screen
   Clears screen and shows the title of the next section, for 2 seconds.
   Don't use in code executed every auto wake-up, to avoid delays.

   Parameters:
     ID of ProgMem text for line 0.
*/
void DisplaySectionScreen(byte Line0TxtID)
{
  LcdNewScreen();
  DisplayUiText(Line0TxtID, TXT_CENTRED | TXT_UNDERLINED);
  delay(2000);
}

enum ui_text_IDs {
  UI_YEAR20,
  UI_MONTH,
  UI_DAY,
  UI_HOUR,
  UI_MINUTE,
  UI_SET_CLOCK,
  UI_PRESS_CHANGE,
  UI_CLOCK,
  UI_UNCHANGED,
  UI_ADJUSTED,
  UI_SUN,
  UI_MON,
  UI_TUES,
  UI_WEDNES,
  UI_THURS,
  UI_FRI,
  UI_SATUR,
  UI_RTC_RESTART,
  UI_NO_UITXTFILE,
  UI_SW_ID_MATCH,
  UI_CSV_READ_ERR,
  UI_NOLOG_FOR_SMS,
  UI_NO_RTC,
  UI_NO_SD_CARD,
  UI_NO_CONFIGFILE,
  UI_CANT_OPEN_LOG,
  UI_FILE_2_SHORT,
  UI_BAD_LOG_FILE,
  UI_UNKNOWN_ERROR,
  UI_WELCOME,
  UI_SLEEPING,
  UI_PRESS_FOR_YES,
  UI_SENDNG_SMS_TO,
  UI_VIEW_SETTINGS,
  UI_START,
  UI_END,
  UI_LOG_EVERY,
  UI_SEND_SMS,
  UI_SMS_EVERY,
  UI_SENDSMSTO_TEL,
  UI_HIVE_ID,
  UI_SEND_TEST_SMS,
  UI_CALIB_TEMP,
  UI_PRESS_RAISES,
  UI_PRESS_LOWERS,
  UI_CONF_CHANGE,
  UI_CALIBRATION,
  UI_MEASUREMENTS,
  UI_SETTINGS,
  UI_TEST_SMS,
  UI_CALIB_WEIGHT,
  UI_REVERT_WEIGHT,
  WT_ZERO_CALIB1,
  WT_ZERO_CALIB2,
  UI_SET_ZERO,
  WT_SCALE_CALIB1,
  WT_SCALE_CALIB2,
  WT_WAIT,
  NUM_OF_UITEXT_IDS     // Must be last element in enum. Used to cross check that SW version and data read into EEPROM from UITEXT.TXT match
};

/*
   Verifies that the text data matches the SW version.
   Checks the number of texts against the size of the enum of text IDs.
   Better than using the length of text data or a checksum as these could be changed by a simple harmless spelling correction!
*/
void VerifyNumberOfTexts(void)
{
  byte textIdsFound = 0;
  byte thisChar;
  for (int i = TEXTS_START_IN_EEPROM; i < 1023; i++) {
    thisChar = EEPROM.read(i);
    if (thisChar == 0) {
      textIdsFound++;
    } else if (thisChar == 0xFF) {
      break; // End of texts reached.
    }
  }
  if (textIdsFound != byte(NUM_OF_UITEXT_IDS)) {
    programERROR = SW_UITEXT_MISMATCH;
  }
}

/* Clear Screen, Display Time HH:MM and BatteryVolts
   IMPORTANT: Avoid any delays, as executes every auto wake-up

    HH:MM      B9.9V     Time   BatteryVolts
    14:08      B8.1V     Time 14:08, Battery 8.1 volts

    Parameter: BatteryVolts (volts x 10)
*/
void ClearAndWriteFirstLineToLcdScreen(int BatteryVolts)
{
  char cstr[SCREEN_BUF_LEN];
  LcdNewScreen();
  DateTime now = rtc.now();
  sprintf_P(cstr, PSTR("%02d:%02d      B%01d.%01dV"), now.hour(), now.minute(), BatteryVolts / 10, BatteryVolts % 10 );
  LcdString(cstr);
}

/* Display Number of Auto Logs and next log time.
   Show on 1st & 2nd line of screen.
   NOTE: The first line already contains "HH:MM      B9.9V", writtem by ClearAndWriteFirstLineToLcdScreen().
   IMPORTANT: Avoid any delays, as executes every auto wake-up

     14:08 0001 B8.1V     9 logs so far
     Next 27/08 15:00     Next log 27th August at 15:00

   Parameters:
     nextLogTime
     CountOfAutoLogs    from the global
*/
void WriteLogCountAndNextToScreen(DateTime nextLogTime)
{
  char cstr[SCREEN_BUF_LEN];
  lcd.setCursor(6, 0); // Write Count to middle of first line. NOTE: The screen first line already contains "HH:MM      B9.9V".
  int numOfRecords = int(CountOfAutoLogs % 10000L); // We limit it to 9999 after which it will just roll over to 0000
  sprintf_P(cstr, PSTR("%04d"), numOfRecords);
  LcdString(cstr);
  lcd.setCursor(0, 1);
  sprintf_P(cstr, PSTR("Next %02d/%02d %02d:%02d"), nextLogTime.day(), nextLogTime.month(), nextLogTime.hour(), nextLogTime.minute());
  LcdString(cstr);
  // Don't add any delays here as they will keep unit awake on logging wake-ups
}

/*
    Display Current Measurements
*/
void DisplayMeasurementsOnLcdScreen(void)
{
  char cstr[SCREEN_BUF_LEN];
  LcdNewScreen();
  // ************ Temperatures ************
  int temperature1 = readTemperatureFromAD(PIN_TEMPERATURE1);
  int temperature2 = readTemperatureFromAD(PIN_TEMPERATURE2);
  sprintf_P(cstr, PSTR("Ext%3dC  Int%3dC"), temperature1 / 10, temperature2 / 10); // Temperatures divided by 10 to get C not Cx10
  LcdString(cstr);
  // ************ Weight ************
  // long WeightTemperatureCorrected = temperatureAdjustWeight(readWeight(), temperature1);  // Deactivated in version 4.3
  long Weight = readWeight();  // Changed to use weight, not WeightTemperatureCorrected
  char resStrW[8];
  //ConvertWgToWkg (resStrW, WeightTemperatureCorrected);
  ConvertWgToWkg (resStrW, Weight);
  sprintf_P(cstr, PSTR("Weight:%skg\0"), resStrW);
  lcd.setCursor(0, 1);
  LcdString(cstr);
  delay(2000);
}

/* Display Error screen

    0123456789012345     Explanation
    ----------------     ----------------------------
  1 ERROR: 99            ERROR and ErrorNumber
  2 Can't Find RTC       Details of error, if available
*/
void DisplayErrorOnLcdScreen(byte progError)
{
  char cstr[SCREEN_BUF_LEN];
  LcdNewScreen();
  sprintf_P(cstr, PSTR("ERROR: %02d"), progError);
  LcdString(cstr);
  if (progError > 0 ) {
    DisplayUiText(GetErrorTextID(progError), TXT_LH | TXT_LINE1);
  }
}


/* ============================= WRITE ERRORS TO ERROR FILE ============================== */
/*
   Write an Error Number and its message to ERROR file.
*/
void WriteErrNumberToFile(byte progError)
{
  char ErrTxt[SCREEN_BUF_LEN + 5]; // For compatability with GetErrorText()
  memset(ErrTxt, '\0', sizeof(ErrTxt)); // Fill with string terminator.
  sprintf_P(ErrTxt,  PSTR("E%02d: \0"), progError);  // Start with 5 characters like this   "E09: "
  GetOrDisplayUiText(GetErrorTextID(progError), TXT_LH,  ErrTxt + 5 );
  WriteErrMsgToFile (ErrTxt);
}

/*
   Start or add to ERROR file.
   Create the ERROR file if it doesn't already exist. Write to it.

   Write the time and the Error or AT message. E.g.:
       2020-11-25 17:35:16
       AT+CREG?
       +CREG: 0,1
       OK

*/
void WriteErrMsgToFile (char * ATorErrorMsg) {
  char timestampBuf[23];
  DateTime eT = rtc.now();       // Choose the actual time, always. 'eT' = 'error Time'

  fileHandle = SD.open(ERROR_FILE_NAME, FILE_WRITE);
  if (fileHandle) {
    sprintf_P(timestampBuf,  PSTR("%04d-%02d-%02d %02d:%02d:%02d, \0"),  eT.year(), eT.month(), eT.day(), eT.hour(), eT.minute(), eT.second()); // Adds string_terminator too
    fileHandle.print(timestampBuf);
    fileHandle.println(ATorErrorMsg);
  } else {
    Serial.println(F("F_Er"));  // "Failed write to ERROR file" Often a symptom of too little dynamic memory dynamic memory, e.g. < 600.
  }
  fileHandle.close();
}

/************************************************************************************************
                                  USER INTERFACE FUNCTIONS
 ************************************************************************************************/

/* ======= USER INTERFACE Functions & Data used potentially in more than one part of UI ======= */

/* SUPER USEFUL TO SAVE DYNAMIC MEMORY https://forum.arduino.cc/index.php?topic=326178.0
    sprintf_P will let you put the format string in progmem.  Little known capital S formatter works with strings in progmem.

    const char string1[] PROGMEM = "Hello";
    const char string2[] PROGMEM = "world";

    char buffer[12];
    sprintf_P (buffer, PSTR("%S %S"), string1, string2);
*/


const char ON_txt[] PROGMEM = "ON";
const char OFF_txt[] PROGMEM = "OFF";


void DisplayTextBufOnLCD(char* ScreenLineBuf, byte Line )
{
  lcd.setCursor(0, Line);
  LcdString(ScreenLineBuf);
}


void DisplayDayOfWeekTextOnLCD(byte DofW )
{
  DofW += UI_SUN;
  DisplayUiText(DofW, TXT_CENTRED);
}


/* Get Error text for LCD or for Error Log file
   Copies 16 chars of text into the buffer supplied by the calling function.
   The text is the error message corresponding to the supplied error number.

   Example:
    0123456789012345     Explanation
    ----------------     ----------------------------
    Can't Find RTC       Details of error, if available
*/
byte GetErrorTextID(byte progError) {
  byte progErrorTextID = UI_RTC_RESTART +  progError - 1;
  if (progErrorTextID >  UI_UNKNOWN_ERROR ) {
    progErrorTextID = UI_UNKNOWN_ERROR;   // All higher error mumbers than the ones we expect are pointed at the ErrUnkownError_txt
  }
  return progErrorTextID;
}

/*
   Polls the Push Button every 5ms for the specified number of seconds.
   Returns TRUE immediately if pressed, else returns FALSE at end.
*/
bool ReadPushButtonForSeconds(int SecsToWait )
{
  SecsToWait = SecsToWait * 200;

  while (SecsToWait) {
    // check if the pushbutton is pressed. If it is, the buttonState is LOW:
    if (readPushButtonStatus()) {
      return true;
    }
    delay(5);
    SecsToWait -= 1;
  }
  return false;
}

/*
   De-Bounced the Push Button.
     Waits for the Push Button to be released, the adds a delay before returning (currently of 40ms x 1 = 40ms).
     Restarts the delay if button pressed again during the delay.
*/
void WaitForPushButtonRelease()
{
  byte MSecsToDelayAfterRelease = 40;
  byte DelayLeft = MSecsToDelayAfterRelease;
  while (DelayLeft) {
    delay(1);
    DelayLeft -= 1;
    // check if the pushbutton is pressed. If it is, the result is LOW:
    if (readPushButtonStatus()) {
      DelayLeft = MSecsToDelayAfterRelease; // Restart the delay
    }
  }
}


/*
  Generic OFFER to do something.
  Centres the supplied text.

  PARAMETERS:
    line0Txt - text ID of PROGMEM text for Line0
    line1Txt - text ID of PROGMEM text for Line1

  RETURNS:
    boolean TRUE if user pushed button.
*/
#define GENERIC_OFFER_OPTION_DELAY 3  // User has this number of seconds to respond if they want to select option.

boolean OfferToUser(byte line0Txt, byte line1Txt )
{
  LcdNewScreen();
  DisplayUiText(line0Txt, TXT_CENTRED);
  DisplayUiText(line1Txt, TXT_CENTRED | TXT_LINE1);
  WaitForPushButtonRelease();
  if (ReadPushButtonForSeconds(GENERIC_OFFER_OPTION_DELAY)) {
    return true;
  }
  return false;
}


/* ================= ADJUST RTC CLOCK DATE AND TIME USER INTERFACE Functions ================== */

/*
   Allows user to update a Date-Time field as shown by this pseudocode.
   DO
     Display the message and the current value.
     Wait for SecsToWait, polling the button
     IF Button NOT pressed THEN
       Return CurrentValue
     ELSE (if the user presses the button)
       Increment the CurrentValue.
       IF CurrentValue exceeds MaxValue, set it to MinValue ENDIF
     ENDIF
   ENDDO

   SCREEN:

      1234567890123456     Explanation
      ----------------     ----------------------------
  1      YEAR: 2020        Starts on current value.
  2   Press to change      Press to increment.

   Returns the value to use.
*/

int ChangeTimeField(byte FieldName, byte CurrentValue, byte MinValue, byte MaxValue, int SecsToWait)
{
  char FieldNameBuf[SCREEN_BUF_LEN];
  char ScreenLineBuf[SCREEN_BUF_LEN];
  while (true) {
    lcd.setCursor(0, 0);
    memset(FieldNameBuf, '\0', sizeof(FieldNameBuf)); // Fill with string terminator.
    GetOrDisplayUiText(FieldName, TXT_LH, FieldNameBuf  );
    sprintf_P(ScreenLineBuf, PSTR("%10s%02d    "), FieldNameBuf, CurrentValue);
    LcdString(ScreenLineBuf);

    DisplayUiText(UI_PRESS_CHANGE, TXT_CENTRED | TXT_LINE1);
    WaitForPushButtonRelease();

    if (ReadPushButtonForSeconds(4)) {
      CurrentValue += 1;
      if (CurrentValue > MaxValue) {
        CurrentValue = MinValue;
      }
    } else {
      return CurrentValue;
    }
  }
}


struct TimeFieldSpec {
  uint8_t MinValue;
  uint8_t MaxValue;
};

//                                 YEAR (last 2 digits)  MONTH    DAY      HOUR     MINUTE
TimeFieldSpec TimeFieldSpecs[] = { {20, 29},             {1, 12}, {1, 31}, {0, 23}, {0, 59} };

#define ADJUST_CLOCK_OPTION_DELAY 3  // User has this number of seconds to respond if they want to adjust clock.
#define DELAY_PER_FIELD 4   // User has this number of seconds to respond if they want to increment a field.
/*
   Dialogue to query the user for changes to the Date-Time fields:  YY:MM:DD HH:MM
   The user has DELAY_PER_FIELD seconds to increment each field before the UI moves on to the next one.
   and then update the RTC with the entered values.
*/
void SetClockDialogue()
{
  DateTime now = rtc.now();
  uint16_t Year =  now.year();

  // Note: Below we pass the lower 2 digits of YEAR only. This works for 2000 to 2099!
  uint8_t CurrentValues[] = { (uint8_t)(Year % 2000), now.month(), now.day(), now.hour(), now.minute() };

  // 30 day months 4 6 9 11   28/29 day month 2.
  uint8_t MonthsOf30Days[] = {4, 6, 9, 11};

  for (int i = 0; i < (sizeof(TimeFieldSpecs) / sizeof(TimeFieldSpec)); i++) {

    // Next few lines are to adjust Max days in month according to month we're in
    uint8_t MaxValueAdjusted = TimeFieldSpecs[i].MaxValue;
    if (i == 2) { // DAYS case
      for (int k = 0; k < sizeof(MonthsOf30Days); k++) {
        if (CurrentValues[1] == MonthsOf30Days[k]) {
          MaxValueAdjusted = 30; // Reduce max days in month to 30 if it's one of the 30 day months.
        }
      }
      if (CurrentValues[1] == 2) { // IF MONTH is 02 (February)
        // Reduce max days in month to 28 or 29 for February.
        MaxValueAdjusted = 28;
        if (CurrentValues[0] % 4 == 0) {
          MaxValueAdjusted = 29;  // Handle leap years of 21st century.
        }
      }
    }
    CurrentValues[i] = ChangeTimeField(byte(i), CurrentValues[i], TimeFieldSpecs[i].MinValue, MaxValueAdjusted, DELAY_PER_FIELD);
  }


  Year = (uint16_t)(CurrentValues[0]) + 2000; // Add back the upper 2 digits of YEAR.
  //                        YEARS             MONTHS             DAYS             HOURS             MINUTES      SECONDS
  rtc.adjust(DateTime(Year, CurrentValues[1], CurrentValues[2], CurrentValues[3], CurrentValues[4], 0 ) );  //set the new time.

  // TEST
  //char testcstr[24];
  //sprintf(testcstr, "TimeYYYYMMDDHHMM:%04d:%02d:%02d:%02d:%02d",Year, CurrentValues[1], CurrentValues[2], CurrentValues[3], CurrentValues[4]);
  //Serial.println(testcstr);
}

/*
     Gives user the option to enter the Set Clock dialogue to adjust the RTC.
     Then gives user confirmation of new or unchanged time


      1234567890123456     Explanation
      ----------------     ----------------------------
  1      SET CLOCK?
  2   Press to change


      1234567890123456     Explanation
      ----------------     ----------------------------
  1       CLOCK
  2     UNCHANGED          or "  ADJUSTED  "


      1234567890123456     Explanation
      ----------------     ----------------------------
  1      WEDNESDAY
  2   02/09/2020 16:45     Actual Date and Time of clock now.
*/
void OfferToAdjustClock()
{
  byte ResultMessage = UI_UNCHANGED;
  if (OfferToUser( UI_SET_CLOCK, UI_PRESS_CHANGE )) {
    SetClockDialogue();
    ResultMessage = UI_ADJUSTED;
  }

  DisplayBannerScreenCentred(UI_CLOCK, ResultMessage);

  // Display the Date and Time
  DateTime now = rtc.now();
  DisplayDayOfWeekTextOnLCD(now.dayOfTheWeek());   // now.dayOfTheWeek() returns Day of week as a uint8_t integer from 0 (Sunday) to 6 (Saturday).
  char cstr[14];
  sprintf_P(cstr, PSTR("%02d/%02d/%04d %02d:%02d"), now.day(), now.month(), now.year(), now.hour(), now.minute() );   // DD/MM/YYYY HH:MM
  lcd.setCursor(0, 1);
  LcdString(cstr);
  delay(5000);
}


/* =========================== CONFIGURATION USER INTERFACE Functions ======================== */

/*
   Displays the CONFIG settings as shown by this pseudocode.
   DO
     Display the setting name on line 0 and the current value on line 1.
     Wait for SecsToWait.
   ENDDO

   SCREEN:

      1234567890123456     Explanation
      ----------------     ----------------------------
  1   Log every:           Name of the CONFIG setting.
  2           12 Hours     The setting value.

  Cases:
    0 "Start";                   // Value: DateTime
    1 "End";                     // Value: DateTime
    2 "Log every:";              // Value: Time and Units(D, H or M), e.g. 12H
    3 "Send SMS:";               // Value: ON/OFF
    4 "SMS every:";              // Value: Time and Units(D only), e.g. 2D
    5 "Send SMS to Tel:";        // Value: Text of tel number
    6 "Hive ID:";                // Value: Text of Hive Identifier

*/
#define CONFIG_SETTINGS_TO_DISPLAY UI_HIVE_ID - UI_VIEW_SETTINGS
void DisplayConfigSettings(void)
{
  char ScreenLineBuf[SCREEN_BUF_LEN + 1];

  for ( byte i = 0; i < CONFIG_SETTINGS_TO_DISPLAY; i++) {
    LcdNewScreen();
    DisplayUiText(UI_START + i, TXT_LH);     // First of the config settins is Logging Start.
    if (i < 2) {
      uint32_t datTim_unix_time;
      if (i == 0 ) {
        datTim_unix_time = LogrCfg.unixTimeSTART;     // Start DateTime
      } else {
        datTim_unix_time = LogrCfg.unixTimeEND;       // End DateTime
      }
      DateTime datTimObj(datTim_unix_time);
      sprintf_P(ScreenLineBuf, PSTR("%02d/%02d/%04d %02d:%02d"), datTimObj.day(), datTimObj.month(), datTimObj.year(), datTimObj.hour(), datTimObj.minute() );   // DD/MM/YYYY HH:MM
    } else if (i == 2 || i == 4) {                    // Log Interval or SMS Interval
      byte val;
      char units;
      if (i == 2 ) {                                  // Log Interval
        val =  LogrCfg.Interval;
        units = LogrCfg.IntervalUnits;
      } else {                                        // SMS Interval
        val =  LogrCfg.DaysPerSMS;
        units = 'D';
      }
      sprintf_P(ScreenLineBuf, PSTR("%8d%c"), val, units);   // Examples 99D    99H    99M
    } else if (i == 3) {                              // Send SMS ON/OFF
      if (LogrCfg.SMS_On) {
        sprintf_P(ScreenLineBuf, PSTR("%9S"), ON_txt);
      } else {
        sprintf_P(ScreenLineBuf, PSTR("%9S"), OFF_txt);
      }
    } else if (i == 5) {                              // Tel Number (to send SMS to)
      sprintf_P(ScreenLineBuf, PSTR("%16s"), LogrCfg.TelNum);
    } else if (i == 6) {                              // Hive ID
      sprintf_P(ScreenLineBuf, PSTR("%10s"), LogrCfg.HiveName);
    }
    DisplayTextBufOnLCD(ScreenLineBuf, 1);
    delay(200);
    WaitForPushButtonRelease();
    ReadPushButtonForSeconds(20);  // Gives the user 20s to read the value and then moves on. Pressing button moves on immediately.
  }
}

void OfferViewConfigSettings(void)
{
  if (OfferToUser( UI_VIEW_SETTINGS, UI_PRESS_FOR_YES )) {
    DisplayConfigSettings();
  }
}

/* =========================== TEST SMS USER INTERFACE Function ======================== */

void OfferTestSMS(void)
{
  if (OfferToUser( UI_SEND_TEST_SMS, UI_PRESS_FOR_YES )) {   // Use same delay as used in Adjust Clock dialogue.
    sendSmsLog();
  }
}


/* ================================= POWER ON/OFF  Functions ================================= */
/*
   Powers OFF the Arduino Nano
   Does this by toggling PIN_POWER_ARDUINO which clears the Power Control FlipFlop thus disconnecting
   the battery.

   Note: the Nano will stay powered if it is also connected to a PC.
*/
void PowerDownArduino(void)
{
  pinMode(PIN_POWER_ARDUINO, OUTPUT);
  digitalWrite(PIN_POWER_ARDUINO, LOW); // Set Active LOW to clear the flipflop and thus power off Arduino.
  delay(500);  // delay 0.5 seconds for switch off.
  digitalWrite(PIN_POWER_ARDUINO, HIGH);
}


/* ============================ DEFINES TO REFERENCE SETTINGS ============================ */
// These defines set the Mode of operation for the UpDownCalibrate() function that is used for calibration of
// both Temperatures sensors T1 & T2, and for the Weight ScaleFactor.

#define MODE_T1_ADJUST 0
#define MODE_T2_ADJUST 1
#define MODE_WEIGHT_SCALE_ADJUST 2

/* ============================ TEMPERATURE MEASUREMENT Functions ============================ */

/*
  Read Temperature from the sensor connected to the specified Analogue input.
  Parameters:
  AdPort - specifies the Analogue input (may be 0 or 1)

  Returns: Temperature in degrees C x 10 (may be negative). E.g. -999 means -99.9C

  TEMPERATURE CALCULATION EXPLANATION
  ===================================
  Arduino 10-bit A/D: 5 volts corresponds to 1024
  Volts = A/D_In * 5 / 1024

  Sensor output is 0 at 0°K (-273.15°C). Errors in output voltage versus temperature are only slope.
  Thus a calibration of the slope at one temperature corrects errors at all temperatures.
  Nominally, the output is calibrated at 10mV/°K.
  Thus:
  Temp K = Volts * 1000/10  output of sensor going to 0V
  Temp C = Temp K - 273
  Temp C = A/D_In * 5 / 1024 * 1000/10 - 273
  = A/D_In * 500 / 1024  - 273
  = A/D_In * 125 / 256  - 273
*/
int readTemperatureWithcalibFromAD(byte AdPort, int CalibConstant) {
  long int TemperatureInVal = 0;   // variable to store the Temperature value read
  long int TempC;                  // For better precision.

  TemperatureInVal = analogRead(AdPort);    // read the Analogue input specified

  //  CALIBRATED INDIVIDUALLY for EACH sensor (don't swap them!). We adjust slope after calibrating against multimeter thermocouple. NOTE Temp x10
  if (AdPort == PIN_TEMPERATURE1) {
    TempC = (((long int)(TemperatureInVal)) * ((long int)(CalibConstant)) / 256L) - 2730L;  // SENSOR 1
  } else if (AdPort == PIN_TEMPERATURE2) {
    TempC = (((long int)(TemperatureInVal)) * ((long int)(CalibConstant)) / 256L) - 2730L;  // SENSOR 2
  }
  //Serial.println(TempC);
  int TempClimited = int(TempC);
  // Now, constrain Temp to +/- 99.9 for formatting reasons. Note that We don't expect
  if (TempClimited > 999) {          // values anywhere near these limits, except from sensor errors/disconnects.
    TempClimited = 999;
  } else if (TempClimited < -999) {
    TempClimited = -999;
  }
  return int(TempClimited);
}

int readTemperatureFromAD(byte AdPort) {
  int CalibConstant;          //  For saving calibration factor of Temperature Sensors.
  int eeAddress = (sizeof(int)) * int(AdPort); //EEPROM address where to read / write the CalibConstant.
  int FactoryCalibConstants[] = { 1255, 1259 };
  EEPROM.get( eeAddress, CalibConstant );  // Get from EEPROM location byte address fro this port.
  if (CalibConstant < 1000 || CalibConstant > 1400 || FIXED_CALIBRATION) {
    CalibConstant = FactoryCalibConstants[AdPort];     // Force the CalibConstant to be the Factory Default for that port, if it's currently a silly value,
    EEPROM.put(eeAddress, CalibConstant);              // as it will be on first ever start up. Also if FIXED_CALIBRATION specified.
  }
  Serial.print(F("T#&CalibFtr "));
  Serial.println(AdPort);
  Serial.println(CalibConstant);
  return readTemperatureWithcalibFromAD(AdPort, CalibConstant);
}


/* =========================== CALIBRATE TEMPERATURE SENSORS USER INTERFACE Functions ======================== */

void OfferCalibrateTemperatureSensors(void)
{
  if (OfferToUser( UI_CALIB_TEMP, UI_PRESS_FOR_YES )) {   // Use same delay as used in Adjust Clock dialogue.
    CalibrateT1T2DegC(0);
    CalibrateT1T2DegC(1);
  }
}


/*
   Function to calibrate the Temperature sensors T1 and T2.

   Allows user to calibrate a T1 or T2 so that they give the correct temperature.
   The user need to have the T1 / T2 sensors at a known temperature, for example by having a thermometer by the sensor
   so that it is exposed to the same temperature, and doing the calibration when the ambient temperature is fairly uniform (i.e. not
   in high winds, rain, or with direct sun on the sensor/thermometer.


     SCREEN:
    =======
       1234567890123456     Explanation
       ----------------     ----------------------------
    1  T1 Ext = 99.9C       Starts on current value.         or   T2 Int = 99.9C
    2   Press to raise      Press to increase reading.       or   Press to lower      Press to decrease reading.


   NOTE: I have numbered settings T1 and T2 as 0 and 1 in the call to UpDownCalibrate() which identifies them, is used to reference EEPROM
         and is also their Analogue Port numbers. If they ever use any other ports than A0 and A1, this SW will need mdifying.

   PARAMETER:
     - AdPort  : The Port number and identifier. Set to 0 for T1, and 1 for T2. IMPORTANT: VALUE MUST BE ONE OF 0 or 1

*/
void CalibrateT1T2DegC(byte AdPort)
{
  int eeAddress = TEMPERATURE_CALIB_T1_IN_EEPROM + (sizeof(int)) * int(AdPort); //EEPROM address where to read / write the CalibConstant.
  UpDownCalibrate(AdPort, eeAddress, 1);
}


/*
   GENERIC Up Down Calibration adjustment, used for Temperatures T1 & T2, and for the Weight Scale Factor
   N.B. Works ONLY with settings that are integers (int).

   DO
     WHILE SecsToWait
       Read and display the Weight and Ask 'Raise?' (the action the user will want to take if the sensor is reading high).
       Poll the button
       IF Button pressed THEN
         Adjust the ScaleFactor to increase the Weight reading.
         Restart SecsToWait
       ENDIF
       Decrement SecsToWait
     ENDWHILE
     WHILE SecsToWait
       Read and display the Weight and Ask 'Lower?' (the action the user will want to take if the sensor is reading high).
       Poll the button
       IF Button pressed THEN
         Adjust the ScaleFactor to decrease the Weight reading.
         Restart SecsToWait
       ENDIF
       Decrement SecsToWait
     ENDWHILE
     Request confirmation and if YES, write new value of CalibConstant to EEPROM.
   ENDDO


   EXAMPLE SCREEN:

      1234567890123456     Explanation
      ----------------     ----------------------------
  1   Weight: 99.999kg     Starts on current value.
  2    Press to raise      Press to increase reading.       or   Press to lower      Press to decrease reading.

   PARAMETERS:
     - Setting         : Sets the Mode of operation. Set to 0 for T1,  1 for T2,  2 for Weight ScaleFactor.
                         Should be one of: MODE_T1_ADJUST, MODE_T2_ADJUST, MODE_WEIGHT_SCALE_ADJUST.

     - eeAddress       : The EEPROM address of the setting
     - adjustmentStep  : The step to increment (or decrement) the adjustment when the user presses Raise (or Lower).

   NOTE: Works ONLY with settings that are ints.
*/
void UpDownCalibrate(byte Setting, int eeAddress, int adjustmentStep)
{
  int TDegx10;
  byte SecsToWait;
  char ScreenLineBuf[SCREEN_BUF_LEN];
  int CalibConstant;          //  For calibration factor of Temperature Sensors.
  EEPROM.get( eeAddress, CalibConstant );    // Read CalibConstant from EEPROM location

  for (byte i = 0; i < 2; i++) {   // When i = 0 do RAISE, when i = 1 do LOWER.
    SecsToWait = 2;
    LcdNewScreen();
    if (Setting == MODE_T1_ADJUST) {
      sprintf_P(ScreenLineBuf, PSTR("T1 Ext ="));
    } else if (Setting == MODE_T2_ADJUST) {
      sprintf_P(ScreenLineBuf, PSTR("T2 Int ="));
    } else {
      sprintf_P(ScreenLineBuf, PSTR("Weight: "));
    }
    LcdString(ScreenLineBuf);
    while (SecsToWait--) {
      if (Setting == MODE_WEIGHT_SCALE_ADJUST) {
        SetScaleAndDisplayWeight(ScreenLineBuf, CalibConstant);
      } else {
        DisplayTemperature(Setting, ScreenLineBuf, CalibConstant);
      }
      LcdString(ScreenLineBuf);
      DisplayUiText(UI_PRESS_RAISES + i, TXT_CENTRED | TXT_LINE1); // When i = 0 display UI_PRESS_RAISES, when i = 1 display UI_PRESS_LOWERS.
      WaitForPushButtonRelease();

      if (ReadPushButtonForSeconds(4)) {
        CalibConstant += (1 - 2 * int(i)) * adjustmentStep;               // When i = 0 adds 1, when i = 1 subtracts 1.
        SecsToWait = 2;  // Restart timeout
      }
    }
  }

  // Confirm New Value
  SecsToWait = UI_UNCHANGED;   // Note: RE-USING spare variable SecsToWait as TextToShow!!
  if (OfferToUser( UI_CONF_CHANGE, UI_PRESS_FOR_YES )) {   // Use same delay as used in Adjust Clock dialogue.
    EEPROM.put(eeAddress, CalibConstant);
    SecsToWait = UI_ADJUSTED;
  }
  DisplayBannerScreenCentred(UI_CALIBRATION, SecsToWait); // Note: RE-USING spare variable SecsToWait as TextToShow!!
}

/*
   Called from UpDownCalibrate() when adjusting a Temperature calibration (of T1 or T2).
*/
void DisplayTemperature(byte Setting, char* ScreenLineBuf, int CalibConstant)
{
  ConvertTx10ToDecimal (ScreenLineBuf, readTemperatureWithcalibFromAD(Setting, (long)(CalibConstant)));
  lcd.setCursor(8, 0);
}



/* ============================ WEIGHT MEASUREMENT Functions ============================ */

/* *******************  CALIBRATION *******************

  The final calibration was base on data obtained by loading the scale with a closed beeless hive (thus constant
  known weight) over a period of spring days with sunny days and cold nights. Also leaving it for periods
  un-loaded and with smaller loads both in the house at about 20C and in garage during cold nights.
  All of this data is in spreadsheet LOGDTA1To1April2020ForCALIBRATION.ods  where it is also analysed
  by sorting data for a given known weight by temperature and fitting a line to it (linear regression) thus
  obtaining a slope for temperature dependency and offset at 0C.

  The slope and offset were very similar for all tested weights (from none to the beeless hive), so they do not
  appear to depend on load.
  zeroOffset = -127g
  slope      = 52 grams/degreeC

*/


/*
  Arduino pin 5 -> HX711 DOUT
  Arduino pin 6 -> HX711 CLK
  Arduino pin 5V -> HX711 VCC
  Arduino pin GND -> HX711 GND
*/


#define PIN_TO_HX711_CLK  5
#define PIN_TO_HX711_DOUT  6

HX711 scale(PIN_TO_HX711_DOUT, PIN_TO_HX711_CLK); // Specify the data pins connected to the HX711 CLK and DOUT.


/*
  Set up the scale.

  We cannot tare it because the hive is always present. We have to obtain this value from zero_factor obtained running the SparkFun_HX711_Calibration sketch
  or running sketch_HX711, or using inbuilt calibration and removing the hive from the stand when setting ZERO.

  We need to allow negatives in case weight decreases.

  Similarly we need to have determined the calibration_factor from one of the above sketches, for use with scale.set_scale(), or use inbuilt calibration with
  a known weight on the stand.

  We run set up only when weighing is required during a wake up from sleep (it isn't always) to save time and power.

  IMPORTANT NOTE: ScaleFactor;  // The HX711 library expects a float, but we store as an int allowing +/- 32,000 but divide by 100 after converting to a
                  float, so range is  +/-320.00

*/
void setupLoadCellHX711() {
  long ZeroFactor;
  int ScaleFactor;  // The HX711 library expects a float, but we store as an int allowing +/- 32,000 but divide by 100 after converting to a float, so range is  +/-320.00
  EEPROM.get( WEIGHT_ZEROFACTOR_IN_EEPROM, ZeroFactor );
  if (ZeroFactor < -2000000L || ZeroFactor > 1000000L || FIXED_CALIBRATION ) {
    RestoreLoadCellFactoryZERO();
  } else {
    scale.set_offset(ZeroFactor);  // set OFFSET, the value that's subtracted from the actual reading (tare weight)
    Serial.print(F("WtZeroFtr "));
    Serial.println(ZeroFactor);
  }
  EEPROM.get( WEIGHT_SCALEFACTOR_IN_EEPROM, ScaleFactor );
  if (ScaleFactor < 500 || ScaleFactor > 10000 || FIXED_CALIBRATION) {           // So limits it to between 5.00 and 100.00
    RestoreLoadCellFactorySCALE();
  } else {
    scale.set_scale(float(ScaleFactor) / 100.0); //Adjust to this calibration factor
    Serial.print(F("WtScaleFtr "));
    Serial.println(ScaleFactor);
  }
}

/*
   NOTE: To find the factory settings to use in the Restore functions below: Comment in the print statements above and calibrate the scale.
   Also: EEPROM.put function uses EEPROM.update() to perform the write, so does not rewrites the value if it didn't change. 
*/
void RestoreLoadCellFactoryZERO() {
  long ZeroFactor = -301492L;    // Set this up to be a good value for the load cells supplied with the unit. Was 293492L
  EEPROM.put(WEIGHT_ZEROFACTOR_IN_EEPROM, ZeroFactor);
  scale.set_offset(ZeroFactor);
}

void RestoreLoadCellFactorySCALE() {
  int ScaleFactor = 1970;        // Set this up to be a good value for the load cells supplied with the unit. N.B. gets converted to float and divided by 100. Was 1900
  EEPROM.put(WEIGHT_SCALEFACTOR_IN_EEPROM, ScaleFactor);
  scale.set_scale(float(ScaleFactor) / 100.0);
}
/*
  Return weight in grams. For return type, an int allows us up to 32kg, an unsigned int up to 64kg, or return grams/2, or use a long.
*/
long readLoadCellHX711()
{
  float units = 0.00;
  if (WeighingInitialised == false) {
    setupLoadCellHX711();
    WeighingInitialised = true;
  }
  scale.power_up();  //  function, to bring the ADC out of low power mode.
  scale.read();      // Waits for the chip to be ready and returns a reading. I use because without it the 1st reading
  // below seems erronious, may be because chip wasn't ready.
  units = scale.get_units(20);
  scale.power_down();
  //Serial.print(F("WtUnits = "));
  //Serial.println(units);
  return long(units);
}

/*
  Return weight in grams. For return type, an int allows us up to 32kg, an unsigned int up to 64kg, or return grams/2, or use a long.
  This calls the function above, but constrains the output to +/- 99,999g for formatting reasons. We don't expect values exceeding
  these limits, except from sensor errors/disconnects.
*/
long readWeight()
{
  return limitWeight( readLoadCellHX711() );
}

long limitWeight(long weight)
{
  const long maxWt = 99999L;
  if (weight > maxWt) {
    weight = maxWt;
  } else if (weight < -maxWt) {
    weight = -maxWt;
  }
  return weight;
}

/*
  NOTE: This feature was DEACTIVATED in version 4.3

  *******************  NEEDS WORK TO GET RIGHT CALIBRATION *******************

  Return weight in grams, temperature adjusted. This calls the function above, but constrains the output to +/- 99,999g for
  formatting reasons. We don't expect values exceeding these limits, except from sensor errors/disconnects.

  NOTE: The zeroOffset and slopeGramsPerDegree

  Parameters:
  Weight in grams
  ExternalTemperature x 10   (i.e. -97 means -9.7C)
*/
long temperatureAdjustWeight(long weightUnadjusted, int externalTemperature)
{
  const long zeroOffset = -127L;
  const long slopeGramsPerDegree = 52L;

  // Elsewhere in this program, IF Temperature exceeds +/- 999 it's limited to +/-999. So using 998 checks for if it has been limited
  // or for some reason is even greater (e.g. if the limit elsewhere in this program is changed from +/-999!).
  // The 999 value is also seen when the temperature sensor is disconnected.
  // IF we don't have a valid temperature, of course we don't want to apply the correction to the weight.
  if ( (externalTemperature > 998) || (externalTemperature < -998) ) {
    externalTemperature = 0;  // This makes the temperature correction below to be zero.
  }
  long weightLimited = weightUnadjusted - zeroOffset - (slopeGramsPerDegree * (long)(externalTemperature)) / 10L;
  return limitWeight( weightLimited );
}


/* =========================== CALIBRATE WEIGHT SENSOR USER INTERFACE Functions ======================== */

/*
   Allows user to calibrate the weight sensor ZERO and SCALE, or restore the 'factory' settings embedded in the SW.

*/
void OfferCalibrateWeightSensor(void)
{
  if (OfferToUser( UI_CALIB_WEIGHT, UI_PRESS_FOR_YES )) {   // Use same delay as used in Adjust Clock dialogue.
    if (OfferToUser( UI_REVERT_WEIGHT, UI_PRESS_FOR_YES )) {
      // Revert to Factory calibration
      RestoreLoadCellFactoryZERO();
      RestoreLoadCellFactorySCALE();
      DisplayBannerScreenCentred(UI_CALIBRATION, UI_ADJUSTED);
    } else {
      CalibrateWeight();
    }
  }
}


/*
   Function to calibrate the weight sensor ZERO and SCALE.

   Setting the ZERO: The user need to be able to remove all unwanted weight if they wish to set the ZERO. This could be everything such that the load cells are exposed and
   completely unloaded, or you could choose to leave the platform on top of the load cells, or even that plus the hive floor.

   Setting the SCALE: The user needs to add a known weight to whatever was on the scale when they set the ZERO. While this could be a fairly small
   weight (e.g. 5kg) a better result will be obtained if it is closer to the weight of a typical beehive, e.g. 20kg.

   Pseudocode:
   DO
     DISPLAY: "Set ZERO only if scale is unloaded"
     Read ZeroFactor from EEPROM
     WHILE SecsToWait
       Read and display the Weight. Ask 'Set ZERO (tare)?'
       Poll the button
       IF Button pressed THEN
         Obtain the new ZeroFactor via scale.set_offset(0L);  ZeroFactor = scale.read_average();
       ENDIF
     ENDWHILE
     Request CONFIRMATION?

     DISPLAY: "Set SCALE only if scale has a known loaded"
     Call the UpDownCalibrate() function to allow the user to calibrate the ScaleFactor
   ENDDO
*/

void CalibrateWeight(void)
{
  byte SecsToWait;
  char ScreenLineBuf[SCREEN_BUF_LEN + 1];
  long ZeroFactor;          //  For saving ZeroFactor factor of weight sensor.

  // First set ZERO
  // ********************* DISPLAY: "Set ZERO only if scale is unloaded"  *********************
  DisplayBannerScreenCentred(WT_ZERO_CALIB1, WT_ZERO_CALIB2);
  delay(4000);
  LcdNewScreen();
  SecsToWait = 2;
  sprintf_P(ScreenLineBuf, PSTR("Weight: "));
  LcdString(ScreenLineBuf);
  while (SecsToWait--) {
    DisplayWeight(ScreenLineBuf);
    LcdString(ScreenLineBuf);
    DisplayUiText(UI_SET_ZERO, TXT_CENTRED | TXT_LINE1);
    WaitForPushButtonRelease();

    if (ReadPushButtonForSeconds(8)) {
      ClearLcdLine(1);
      DisplayUiText(WT_WAIT, TXT_CENTRED | TXT_LINE1);
      scale.power_up();  //  function, to bring the ADC out of low power mode.
      scale.read();  // Waits for the chip to be ready and returns a reading. I use because without it the 1st reading
      scale.set_offset(0L);
      ZeroFactor = scale.read_average(20); //Get a baseline reading. IMPORTANT: The scale must be powered up!! (N.B.DisplayWeight() turns it off again).
      //This is used to tare the scale when the hive is removed from the stand.
      scale.set_offset(ZeroFactor);  // set OFFSET, the value that's subtracted from the actual reading (tare weight)
      SecsToWait = 2;  // Restart timeout. Could instead just break, but this allows user to see what zero is achieved.
    }
  }
  //scale.power_down();

  // Confirm New Value
  SecsToWait = UI_UNCHANGED;   // Note: RE-USING spare variable SecsToWait as TextToShow!!
  if (OfferToUser( UI_CONF_CHANGE, UI_PRESS_FOR_YES )) {   // Use same delay as used in Adjust Clock dialogue.
    EEPROM.put(WEIGHT_ZEROFACTOR_IN_EEPROM, ZeroFactor);
    SecsToWait = UI_ADJUSTED;
  }
  DisplayBannerScreenCentred(UI_CALIBRATION, SecsToWait); // Note: RE-USING spare variable SecsToWait as TextToShow!!


  // Then set SCALE
  // ********************* DISPLAY: "Set SCALE only if scale has a known loaded"  *********************
  DisplayBannerScreenCentred(WT_SCALE_CALIB1, WT_SCALE_CALIB2);
  delay(4000);
  UpDownCalibrate(MODE_WEIGHT_SCALE_ADJUST, WEIGHT_SCALEFACTOR_IN_EEPROM, -10);
}



/*
   Called from UpDownCalibrate() when adjusting the ScaleFactor to calibrate the weight scale.
*/
void SetScaleAndDisplayWeight(char* ScreenLineBuf, int CalibConstant) {
  ClearLcdLine(1);
  scale.set_scale(float(CalibConstant) / 100.0);
  DisplayWeight(ScreenLineBuf);
}

/*
   Called from CalibrateWeight() and SetScaleAndDisplayWeight() when adjusting the ZeroFactor and ScaleFactor respectively.
*/
void DisplayWeight(char* ScreenLineBuf) {
  char weightInKg[8];
  ConvertWgToWkg (weightInKg, readLoadCellHX711());
  lcd.setCursor(7, 0);
  sprintf_P(ScreenLineBuf, PSTR("%skg"), weightInKg);
}


/* ============================ BATTERY VOLTAGE Functions ============================ */

/*
  Return BatteryVolts * 10 of the main PP3 nominal 9V battery. For DEBUG: Display Battery Voltage on Serial Monitor
*/
int batteryVoltageOfPP3()
{
  long int BatteryInVal;       // variable to store the Battery voltage value read
  long int BatteryVolts;       // variable to store the Battery voltage calculated

  BatteryInVal = analogRead(PIN_BATLEVEL_BUTTON);    // read the Analogue input pin shared between Battery Level and Push Button

  // SHOW SENSOR TEMPERATURE AND BATTERY VOLTAGE
  BatteryVolts = BatteryInVal * 5 * 2 * 10 / 1024;  // *2 because we have a potential divider to halve input volts.
  // sprintf(cstr, "T % 02d. % 1dC B % 1d. % 1dV", (int)(TempC/10), (int)(TempC%10), (int)(BatteryVolts / 10), (int)(BatteryVolts % 10) );
  // LcdString(cstr);
  //Serial.print(F("BatteryVolts: "));
  //Serial.print(int(BatteryVolts / 10));
  //Serial.print(F("."));
  //Serial.println(int(BatteryVolts % 10));
  return int(BatteryVolts);
}


/*
   Arduino (Atmega) pins default to inputs, so they don't need to be explicitly declared as inputs with pinMode() when you're using them as inputs.
   read the state of the pushbutton. Return TRUE if pressed.
*/
bool readPushButtonStatus()
{
  int volts = batteryVoltageOfPP3();
  if (volts < 30) {
    return true;   // If button pressed, input should be almost zero (n.b. 30 means 3.0V).
  }
  return false;
}



/* ============================ TC35 Siemens GSM Module Functions ============================ */

// ======================== AT STRINGS ====================
const char AT_0[] PROGMEM = "AT";
const char AT_1[] PROGMEM = "AT+CSQ";
const char AT_2[] PROGMEM = "AT+CPIN?";
const char AT_3[] PROGMEM = "AT+CREG?";
const char AT_4[] PROGMEM = "AT+COPS?";
const char AT_5[] PROGMEM = "AT+CMGF=1";
const char AT_6[] PROGMEM = "SMS";         // Not sent but used as a flag to tell us to start SMS with  "AT+CMGS=\"" and tel number.
const char AT_7[] PROGMEM = "Title";       // Not sent but used as a flag to tell us to send the Title (column headings row) of SMS message.
const char AT_8[] PROGMEM = "Data";        // Not sent but used as a flag to tell us to send the Data rows of SMS message.
const char AT_9[] PROGMEM = "EndSMS";      // Not sent but used as a flag to tell us to send the terminator 'Ctrl Z' of SMS message.
const char AT_10[] PROGMEM = "AT^SMSO";

// Table to refer to AT strings.
const char *const AT_string_table[] PROGMEM = {AT_0,  AT_1,  AT_2,  AT_3,  AT_4,  AT_5, AT_6,  AT_7,  AT_8,  AT_9,  AT_10 };

// Table to refer to delays after AT commands (in 1/10 seconds).
const byte AT_delay_table[] PROGMEM =         {  20,    35,     1,     1,     1,     1,    2,     1,     1,    50,     30 };


#define AT_CMDS_LEN (sizeof(AT_delay_table)/sizeof(byte))   // Length of the table - IMPORTANT: KEEP THE AT_string and AT_delay TABLES MATCHING!
#define AT_CMDS_STEP_CREG 3   // IMPORTANT: MUST match position of "AT+CREG?" in the AT_string_table table above!

/* "AT", "AT+CSQ", "AT+CPIN?", "AT+CREG?", "AT+COPS?", "AT+CMGF=1"

   "AT"        - Get attention
   "AT+CSQ"    - Signal strength. First # is dB strength. Should be higher than around 5. Range is 0-31.
                 Signal quality test, value range is 0-31 , 31 is the best
                 Check the 'signal strength' - the first # is dB strength, it should be higher than around 5. Higher is better.
                 Of course it depends on your antenna and location!
   "AT+CPIN?"  - Is PIN required? READY = 'No', SIM PIN = 'MT is waiting UICC/SIM PIN to be given'.
   "AT+CREG?"  - Check that you’re registered on the network. The second # should be 1 or 5. 1 indicates you are registered to
                 home network and 5 indicates roaming network. Other than these two numbers indicate you are not registered to
                 any network.  So   +CREG: 0,1  is good,   +CREG: 0,2  is not.
   "AT+COPS?"  - Ask the name of the GSM service provider. Good:     +COPS: 0,0,"ASDA Mobile "        Not Good:    +COPS: 0
   "AT+CMGF=1" - Selects SMS message format as text. Default format is Protocol Data Unit (PDU).

*/


char* GetPointerToProgmemATCmndText(byte TextIdx )
{
  return (char *)pgm_read_word(&(AT_string_table[int(TextIdx)])); // Necessary casts and dereferencing.
}


void ReadProgmemATCmndTextToBuf(char* Buf, byte TextIdx )
{
  strcpy_P(Buf, GetPointerToProgmemATCmndText(TextIdx) ); // Necessary casts and dereferencing. See: https://www.arduino.cc/reference/en/language/variables/utilities/progmem/
}

/*
   Get the delay time to wait after the AT Ccommand is sent.

*/
byte GetAtDelay(byte AtIdx )
{
  return pgm_read_byte(&(AT_delay_table[int(AtIdx)])); // Necessary casts and dereferencing.
}

/*
   Powers UP and Activates the TC35 GSM Module

   From Siemens TC35 / TC37 Hardware Interface Description:
     Timing of the ignition process
     ==============================
     When designing your application platform take into account that powering up TC35/TC37 requires the following steps.
       a) The ignition line cannot be operated until V BATT+ passes the level of 3.0V.
       b) 10ms after V BATT+ has reached 3.0V the ignition line can be switched low. The duration of the falling edge must not exceed 1ms.
       c) Another 100ms are required to power up the module.
       d) Ensure that V BATT+ does not fall below 3.0V while the ignition line is driven. Otherwise the module cannot be activated.
          If the VDDLP line is fed from an external power supply as explained in Chapter 3.3.4, the /IGT line is HiZ before the rising edge of VBATT+.

     From Ignition going low for 100ms, "Figure 7: Power-on by ignition signal" shows a maximum of 900mS until the modem is ready
     for AT commands, i.e. 800mS after Ignition line goes high again.
*/
void PowerUpAndActivateGSM(void)
{
  pinMode(PIN_POWER_GSM, OUTPUT);
  digitalWrite(PIN_POWER_GSM, HIGH); // Set HIGH to switch on power to TC35.
  delay(20);                         // Allow time for power up. Supply must have reached 3V for 10ms before Ignition goes low.
  ActivateIgnitionGSM();
  delay(800);                        // After end of Ignition, allow 800mS before using modem.
}

/*
   Activate the TC35 GSM Module
   Does this by toggling the IGNITION input of the TC35 Module to LOW, and then setting it to an input (so high impedance) again.

   From Siemens TC35 / TC37 Hardware Interface Description:
     To switch on TC35/TC37 the IGT (Ignition) signal needs to be driven to ground level for at least 100ms. This can be
     accomplished using an open drain/collector driver in order to avoid current flowing into this pin.
*/
void ActivateIgnitionGSM(void)
{
  pinMode(PIN_GSM_IGT, OUTPUT);
  digitalWrite(PIN_GSM_IGT, LOW);  // Set LOW to 'press the ignition button' of the TC35.
  delay(120);                      // delay of minimum 100ms (see comment above).
  pinMode(PIN_GSM_IGT, INPUT);     // Set it to an input (so high impedance) again, thus 'releasing' the button.
}


/*
   Powers DOWN the TC35 GSM Module

   ADDITIONALLY, BEFOR CALLING THIS FUNCTION, WE SEND AT^SMSO
   From Siemens TC35 / TC37 Hardware Interface Description, section 3.3.4.1 Turn off GSM engine using AT command:
     The best and safest approach to powering down the engine is to issue the AT^SMSO command. This procedure
     lets the engine log off from the network and allows the software to enter into a secure state and to save
     data before disconnecting the power supply.

     See also AT Command Set, section 6.18 AT^SMSO Switch off mobile station:
       Test command:    AT^SMSO=?     Response: OK
       Execute command: AT^SMSO       Response: ^SMSO: MS OFF OK

       Note: "Section 6 Siemens defined AT commands for enhanced functions": Self-defined commands do not have to be
       implemented in accordance with the official syntax. The +C string can therefore be replaced by ^S (^ = 0x5E).
*/
void PowerDownGSM(void)
{
  digitalWrite(PIN_POWER_GSM, LOW); // Set LOW to switch OFF power to TC35.
}


/*
   Send SMS Log

   Does whole job from power up modem, registering, sending and powering down.

   Parameters:
     LogrCfg.TelNum - global char array containing the Tel Mum where SMS is to be sent, complete with international prefix, e.g. +441234567890

*/
#define SMS_MAX_DATA_LINES 6    // This is refers to how many lines of record data will fit in a 160 character SMS. once any title lines have been written
// It depends of how the SMS is laid out and what is included.

#define SMS_MAX_CREG_RETRIES 5    // If the first CREG fails, does this number of retries, with growing delays between each.

void sendSmsLog()
{
  char TextForSmsSendCommand[34];  // MUST BE SURE NO LINE IN SMS IS LONGER THAN THIS, or need to increase buffer size.
  byte OK_Reg_Seen;
  byte OK_Reg_RetryCount = SMS_MAX_CREG_RETRIES;  // If the first CREG fails, does this number of retries.
  // === Display Sending SMS message ===
  //  Sending SMS to
  //   +440123456789
  LcdNewScreen();
  DisplayUiText(UI_SENDNG_SMS_TO, TXT_LH);
  lcd.setCursor(0, 1);
  sprintf_P(TextForSmsSendCommand, PSTR("%16s"), LogrCfg.TelNum);  // N.B. Borrow the buffer to write to the screen, instead of a separate ScreenBuf!
  LcdString(TextForSmsSendCommand);

  // === Begin serial communication with Siemens GSM module ===
  SoftwareSerial mySerial(PIN_GSM_TX, PIN_GSM_RX);
  //Serial.println(F("SIMPLE VERSION.  Switch ON GSM and Activate Ignition..."));
  PowerUpAndActivateGSM();
  // mySerial.begin(9600);  // Use 9600 baud rate to talk to modem ALWAYS TRUE? WHAT IS DEFAULT??
  mySerial.begin(19200);  // Use 19200 baud rate to talk to modem I believe this is the DEFAULT
  delay(3500);  // By trial and error, found that MODEM doesn't respond until after 3s or so.
  /*
     This loop sends a sequence of AT commands with delays after each.
     See the ProgMem section for the sequence of commands and the delays in seconds we wait after each, plus comments.
  */
  for ( int i = 0; i < AT_CMDS_LEN; i++) {
    //Serial.println(GetAtDelay(i));
    byte UpdateSerialTimeOut = 200;  // default to 200 x 20ms = 4000ms = 4s  // For commands that should return OK (wich cuts short the wait), wait up to 4s. For others we modify below to shorter waits.
    OK_Reg_Seen = 0;
    ReadProgmemATCmndTextToBuf(TextForSmsSendCommand, i);
    Serial.println(TextForSmsSendCommand);
    WriteErrMsgToFile (TextForSmsSendCommand); //Include normal AT Commands in the ERROR log, for debug in case anything goes wrong.
    if (TextForSmsSendCommand[0] == 'D') {                              // Send the Data rows of SMS message.
      GetAndFormatDataLines(&mySerial, TextForSmsSendCommand);
      UpdateSerialTimeOut = 10;  // 10 x 20ms = 200ms = 0.2s  // Wait only a short time as there is no OK response to terminate the wait until the whole SMS is completed.  
    } else {
      if (TextForSmsSendCommand[0] == 'E') {                            // Send the terminator 'Ctrl Z' of SMS message.
        // The text message is followed by a ‘Ctrl+z’, which is actually a 26th non-printing character in ASCII table.
        // So, we need to send 26DEC (1AHEX) once we send a message.
        mySerial.write(26);
      } else {
        boolean TextForSmsChanged = false;
        if (TextForSmsSendCommand[0] == 'S') {                          // Start SMS with  "AT+CMGS=\"" and tel number.
          sprintf_P(TextForSmsSendCommand, PSTR("AT+CMGS=\"%s\""), LogrCfg.TelNum);
          TextForSmsChanged = true;
          UpdateSerialTimeOut = 10;  // 10 x 20ms = 200ms = 0.2s  // Wait only a short time as there is no OK response to terminate the wait until the whole SMS is completed.
        } else if (TextForSmsSendCommand[0] == 'T') {                   // Send the Title (column headings row) of SMS message.
          GetAndFormatTitleLines(TextForSmsSendCommand); // e.g. "B8.3V\nhh:mm   Peso Ext Int\n". Maybe add Hive Number, e.g. H123
          TextForSmsChanged = true;
          UpdateSerialTimeOut = 10;  // 10 x 20ms = 200ms = 0.2s  // Wait only a short time as there is no OK response to terminate the wait until the whole SMS is completed.
        }
        if (TextForSmsChanged) {
          Serial.println(TextForSmsSendCommand);
        }
        mySerial.println(TextForSmsSendCommand);
      }
      //Serial.println(OK_Reg_Seen);  // 1=OK Seen. 3=OK seen and Registered seen.
    }
    delay(100 * GetAtDelay(i));
    OK_Reg_Seen = updateSerial(&mySerial, UpdateSerialTimeOut);
    // IF i == REGISTRATION CREG and result is NOT REGISTERED THEN Don't advance i, delay a few secs and Retry until a retryCount is exhausted.
    if ( (i == AT_CMDS_STEP_CREG) && (OK_Reg_Seen != 3) && OK_Reg_RetryCount--  ) {
      // delay(5000 + (SMS_MAX_CREG_RETRIES - int(OK_Reg_RetryCount) )*1000 ); // Grows the delay each time it retries.
      delay(5000); // REMOVED growing the delay, i.e. the line abobe commented out.  
      i--; // Repeat the CREG step until it registers of retry count exceeded.
    }
  }
  PowerDownGSM();
}


/*
   Fetches and sends to the MODEM, the data lines for the SMS message.

   Parameters:
     mySerial - pointer to SoftwareSerial object.
     TextForSmsSendCommand - Pointer to buffer to write text to.
*/
void GetAndFormatDataLines(SoftwareSerial * mySerial, char *TextForSmsSendCommand) {
  fileHandle = SD.open(DATA_FILE_NAME);
  int DataLinesLeft = SMS_MAX_DATA_LINES;
  uint16_t fetchFromDistanceBack;
  uint16_t RecordsSinceLastSMS = gLogsPerSMS;
  uint16_t LogsPerLineOfMsg = 1;
  unsigned long  numOfRecords =  fileHandle.size() / ((unsigned long)(LOG_RECORD_LENGTH)) ;

  unsigned long  numOfDataRecords =  numOfRecords;
  if (numOfDataRecords > 0L) {
    numOfDataRecords = numOfRecords - 1L;   // Avoid negative, but otherwise Subtract 1 because we want the NUMBER of DATA records, i.e. not including the Colunm titles row.
  }

  if (numOfDataRecords == 0L) {
    sprintf_P(TextForSmsSendCommand, PSTR("ERROR: No data.")); // N.B., No newline because for the last line of the message, it causes an error to have a \n.
  } else {
    if (numOfDataRecords <= ((unsigned long)(DataLinesLeft))) {
      // Then ALL the records will fit into the SMS
      DataLinesLeft = int(numOfDataRecords); // Cannot fetch more records than there are in the file!
      fetchFromDistanceBack = int(numOfDataRecords);  // And LogsPerLineOfMsg is already defaulted to 1
    } else {
      if (numOfDataRecords < ((unsigned long)(RecordsSinceLastSMS))) {
        RecordsSinceLastSMS = int(numOfDataRecords); // Cannot go back over more records than there are in the file! The -1 is to make this similar to the since case
      }
      if (DataLinesLeft >= int(RecordsSinceLastSMS)) { // If there are more available lines in SMS, than records since last SMS, we choose to fetch earlier ones and fill the SMS.
        fetchFromDistanceBack = DataLinesLeft;   // And LogsPerLineOfMsg is already defaulted to 1
      } else {
        // Calculate start and steps to get a uniformly spaced sample of the logs
        LogsPerLineOfMsg = RecordsSinceLastSMS / SMS_MAX_DATA_LINES; // Normally gLogsPerSMS but less if there are fewer logs in file, e.g. only first.
        //if (RecordsSinceLastSMS % SMS_MAX_DATA_LINES) {LogsPerLineOfMsg++;} // Round up if above division not exact
        fetchFromDistanceBack = 1 + (LogsPerLineOfMsg * (SMS_MAX_DATA_LINES - 1));
      }
      //Serial.println(F("NumDataRecs, RecSin, L/Ln & DisBak:"));
      //Serial.println(int(numOfDataRecords));
      //Serial.println(RecordsSinceLastSMS);
      //Serial.println(LogsPerLineOfMsg);
      //Serial.println(fetchFromDistanceBack);
    }

    while (DataLinesLeft > 0) {
      GetAndFormatDataLine(numOfRecords - ((unsigned long)(fetchFromDistanceBack)), TextForSmsSendCommand);  // returns -1 if there was no data available, otherwise how many left to fetch.
      Serial.println(TextForSmsSendCommand);
      if (DataLinesLeft == 1) {                                       // For the last line of the message, it causes an error to have a newline.
        TextForSmsSendCommand[strlen(TextForSmsSendCommand) - 1] = 0; // So if last line, replace the \n at the end of the string with a string terminator.
      }
      mySerial->print(TextForSmsSendCommand);
      delay(200);
      DataLinesLeft--;
      if (DataLinesLeft >= fetchFromDistanceBack - 1) { // IF remaining lines available in SMS >= to remaining records in log (N-RecordToGet)
        LogsPerLineOfMsg = 1;
      }
      fetchFromDistanceBack = fetchFromDistanceBack - LogsPerLineOfMsg;
    }
  }
  fileHandle.close();
}

/*
   Returns the title lines for the SMS message.
   Expand to measure and use the battery voltage at this moment.

   Example Display [27 chars] (JUST within 160 SMS limit if 6x22=132 data lines, + 27=159):
     B7.3V
     dd hh:mm Peso Ext Int

   Alternative Example Display using kg [27 chars]:
     B7.3V
     dd hh:mm  kg  Ext Int

   Alternative Example Display using kg and with Hive Name [32 chars]. (BUT THIS TAKES US OVER 160 SMS limit if 6x22=132 data lines, + 32=164):
     C003 B7.3V
     dd hh:mm  kg  Ext Int

   Parameter:
     textForSms - Pointer to buffer to write text to.
*/
void GetAndFormatTitleLines(char *textForSms)
{
  int BatteryVolts = batteryVoltageOfPP3();  // Will be different because GSM switched on!
  //  sprintf_P(textForSms, PSTR("B%01d.%01dV\ndd hh:mm Peso Ext Int\0"), BatteryVolts / 10, BatteryVolts % 10 ); // Use this line if weight will be in grams.
  sprintf_P(textForSms, PSTR("%s B%01d.%01dV\ndd hh:mm  kg  Ext Int\0"), LogrCfg.HiveName, BatteryVolts / 10, BatteryVolts % 10 ); // Use this line if weight will be in kg.
}


/*
   Returns a data line for the SMS message.

   Example Display [22 chars/line, including \n]:
     dd hh:mm Peso Ext Int
     26 14:00 33521g 23 33
     27 14:00 34735g 19 34

   Alternative Example Display using kg [22 chars/line, including \n]:
     dd hh:mm  kg  Ext Int
     26 14:00 33.521 23 33
     27 14:00 34.735 19 34

   Parameters:
     recordNum - Number of record to format for the the SMS.
     textForSms - Pointer to buffer to write text to.

   Returns:
     Writes data line to textForSms.
*/
#define weightInKg_BUF_LEN 8
#define dateTimeBuf_BUF_LEN 20
void GetAndFormatDataLine(unsigned long recordNum, char *textForSms)
{
  char weightInKg[weightInKg_BUF_LEN];    // Used to get Weight,
  char dateTimeBuf[dateTimeBuf_BUF_LEN];  // Used to get Temperatures, and then DateTime. Must hold longest field with delimiter and zero byte.
  if (getColFromRecord(recordNum, LOG_RECORD_WEIGHT_COL, weightInKg, weightInKg_BUF_LEN) ) {
    weightInKg[strlen(weightInKg) - 1] = 0; // Kill off the last digit, so that we have 2 decimal places, not 3. But not if getCol failed.
  }
  getColFromRecord(recordNum, LOG_RECORD_TEMP1_COL, dateTimeBuf, dateTimeBuf_BUF_LEN);
  int t1 = getIntegerFromDecimalTxt(dateTimeBuf) / 10;
  getColFromRecord(recordNum, LOG_RECORD_TEMP2_COL, dateTimeBuf, dateTimeBuf_BUF_LEN);
  int t2 = getIntegerFromDecimalTxt(dateTimeBuf) / 10;
  getColFromRecord(recordNum, LOG_RECORD_DATETIME_COL, dateTimeBuf, dateTimeBuf_BUF_LEN);

  // FORMATTING:   sprintf USES:   %[flags][width][.precision][length]specifier
  // The dateTime string we supply is always 8 chars long "DD HH:MM" so we don't need to specify its length.
  // The weight is a long with a max of 99999 (=99kg) and we allow a sign in case of negatives due to tare of the scale.
  //    % 06ld : ' ' = a space is added if no '-' sign; '0' = padding is with leading 0s; 6 = min field width; 'l' = argument is a long; 'd' give as decimal (not octal!) integer.
  // The temperatures t1 & t2 are in a field of min width 3. The max we supply is 99 so they will have a leading space, or minus sign if negative.
  // TOTAL characters = 8 + 6 + g + 3 + 3 + \n = 22   E.g.: "09 21:30 02493g 10  9"     "02 11:00 36930g 20 34"
  // TOTAL characters = 8 + 6 + 3 + 3 + \n = 21   E.g.: "09 21:30  2.49 10  9"     "02 11:00 36.93 20 34"

  sprintf_P(textForSms, PSTR("%s%6s%3d%3d\n\0"), dateTimeBuf + 8, weightInKg, t1, t2);
}


#define MAX_AT_CMND_SOUGHT_RESPONSE 3

typedef struct {
  char SoughtResponse[MAX_AT_CMND_SOUGHT_RESPONSE + 1];  // +1 for terminator
  byte LeftToRecognise;
} AT_Response;

boolean CheckResponse(char charFromModem, AT_Response *Resp);

/*
   Structure object to enable us to detect some of the expected responses from the GSM MODEM to AT commands.
   For Registration we should see two single digit numbers "n,n". The second # should be 1 or 5. 1 indicates you are registered to
   home network and 5 indicates roaming network. Other than these two numbers indicate you are not registered to any network.
   So   +CREG: 0,1  is good,   +CREG: 0,2  is not.
   NOTE: The recognition technique ONLY WORKS if the first character is not repeated in the SoughtString.
*/

boolean CheckResponse(char charFromModem, AT_Response *Resp) {
  if (Resp->LeftToRecognise == 0) {
    return true;
  } else {
    if (charFromModem == Resp->SoughtResponse[strlen(Resp->SoughtResponse) - Resp->LeftToRecognise]) {
      Resp->LeftToRecognise--;
      if ( Resp->LeftToRecognise  == 0) {
        //Serial.print("Saw ");
        //Serial.println(Resp->SoughtResponse);
        return true;
      }
    } else if (charFromModem == Resp->SoughtResponse[0]) {
      Resp->LeftToRecognise = strlen(Resp->SoughtResponse) - 1;
    } else {
      Resp->LeftToRecognise = strlen(Resp->SoughtResponse);
    }
  }
  return false;
}


#define CHECK_OK_AT_RESP 0
#define CHECK_CREG_AT_RESP 1
#define CHECK_REGHOME_AT_RESP 2
#define CHECK_REGROAMING_AT_RESP 3
/*
 * Gets any bytes sent back by MODEM in response to AT commands, and copies them to: 
 *    a) The terminal, for when debugging connected to a PC.  
 *    b) The ERROR.TXT file, for debugging any problems that arise in the field.
 *    
 * Also monitors for OK response and for responses to CREG query to see if we ar registered on a  
 * network (whether Home or Roaming). Returns with a shorter timeout delay if "OK" seen. 
 * 
 * PARAMETERS:
 *   mySerial - the Serial object to write to the MODEM 
 *   MaxTimeoutPeriod - in multiples of 20ms. Only a byte, so maximum is 255. Example 200 gives  200x20 = 4000ms = 4s
 * 
 * RETURNS:
 *   1  = OK Seen.
 *   2  = Registered Seen (But not OK) - this is unlikely to occur.
 *   3  = Registered Seen and OK seen. This is used to see if retrying CREG is needed.
 */
byte updateSerial(SoftwareSerial * mySerial, byte MaxTimeoutPeriod)
{
  fileHandle = SD.open(ERROR_FILE_NAME, FILE_WRITE);
  AT_Response AtResps[] = { { {"OK\r"}, 3}, { {"EG:"}, 3}, { {",1"}, 2}, { {",5"}, 2} };
  byte OK_Seen = 0;
  char charFromModem;
  byte InTimeoutPeriod = MaxTimeoutPeriod;  // Max is 255.  200x20=4000ms
  while (InTimeoutPeriod--) {
    while (mySerial->available())
    {
      charFromModem = mySerial->read();
      Serial.write(charFromModem);//Forward what Software Serial received to Serial Port
      if (fileHandle) { // WRITE TO ErrorLog all text received back from MODEM
        fileHandle.write(charFromModem);
      }
      if (CheckResponse(charFromModem, &AtResps[CHECK_CREG_AT_RESP]) ){
        if (CheckResponse(charFromModem, &AtResps[CHECK_REGHOME_AT_RESP]) || CheckResponse(charFromModem, &AtResps[CHECK_REGROAMING_AT_RESP]) ) {
          OK_Seen = OK_Seen | 2;  // Set the second bit if Registration OK
          //Serial.println("Registered");
        }
      }
      if ( CheckResponse(charFromModem, &AtResps[CHECK_OK_AT_RESP]) ) {
        OK_Seen = OK_Seen | 1;  // Set the first bit if 'OK' seen.
        InTimeoutPeriod = 10;  // Restart timeout after OK received, but for only 10x5=50ms.
      } else {
        InTimeoutPeriod = MaxTimeoutPeriod; // Restart timeout after anything received.
      }
    }
    delay(20);
  }
  fileHandle.close();
  return OK_Seen;
}


/* ============================ PROGRAM SET UP ============================ */
/* Set programERROR to 0 (none)
  Determine Wake-Up reason (Manual, from battery connected or push button; or RTC alarm). Check alarmFired() to determine.
  IF Wake-Up reason was 'Manual' THEN set DoManualLog TRUE     (in order to take a single manual log)
  Initialise Serial, SD Card interface, Real Time Clock
*/


/* SET UP  */
void setup(void)
{
  bool setupSDisOK;
  programERROR = NO_ERRORS;
  while (!Serial); // for Leonardo/Micro/Zero
  Serial.begin(9600);

  setupSDisOK = setupSD();
  setupRTC();
  // set date time callback function
  SdFile::dateTimeCallback(dateTime);
  LcdSetup();

  /* Check that SD Card is OK and CONFIG file is present. Set relevant programERROR if not. */
  if (!setupSDisOK) {
    // Failed to set up SD card. setupSDisOK == False
    programERROR = ERR_NO_SD_CARD;
    //Serial.println(F("Cannot setup SD"));
  } else if (!SD.exists(CONFIG_FILE_NAME)) {
    programERROR = ERR_NO_CONFIGFILE;
    //Serial.println(F("No CONFIG file")); // CONFIG File doesn't exist
  }

  if (programERROR < FIRST_FATAL_ERROR) // Only errors from FIRST_FATAL_ERROR and upwards are fatal. So OK to proceed.
  {
    /* Read the CONFIG file on SD */
    char destBuf[20];  // Must hold longest field with delimiter and zero byte.
    getConfigData('*', destBuf);
  }

  // Read English or Spanish UI Texts from UITXT if either is specified. Otherwise we just assume the EEPROM already contains the UI texts.
  //Serial.write(LogrCfg.Language);
  //Serial.println(F(" - "));
  if (LogrCfg.Language == 'E') {
    //Serial.println(F("English"));
    getUiTexts(2);
  } else if (LogrCfg.Language == 'S') {
    //Serial.println(F("Spanish"));
    getUiTexts(3);
  }
  VerifyNumberOfTexts();
  if (!rtc.alarmFired(1)) {
    ManualWakeUp = true;
    DoManualLog = true;   // Optional, if we want manual logs.
    //DisplayBannerScreen(Bienvenido_txt, Welcome_txt);
    DisplaySectionScreen(UI_WELCOME);
  }
}


/*
  MAIN LOOP
  =========
  ******************************************************************************************
  We only get to here if it was a manual wake up (not alarm) or we are continuously powered

  Do any manual log requested here instead ???

  Display the Measurements UI
  IF No programERRORs
    Display how long until the NextLog, and current Temperature and Weight measurements on the LCD screen
  ELSE
    Display the ERROR number and Error details on the LCD screen
  ENDIF

  Delay several seconds to allow user to read a stable display before we loop back to start of main loop.

  ENDIF
  POWER DOWN (Clear the Power Control FlipFlop by toggling PIN_POWER_ARDUINO).

  End of MAIN LOOP - this is the end of the program! However the loop will repeat if Nano is powered from PC

  ================================================================================================= */


/* ============================ PROGRAM MAIN LOOP VARIABLES ============================ */

/* ============================ PROGRAM MAIN LOOP ============================ */
void loop(void)
{
  /* We can get here because of either: (a) the alarm went off and powered up the Nano; (b) The unit was just
    switched ON or the Push Button pressed, powering up the Nano; (c) The Nano is connected to a PC during test
    and therefore continuously powered. */

  boolean DoAlarmLog = false;    // Set true if the alarm has gone off, whether causing the wake up or while Nano powered by PC.

  /* Read Battery Voltage
    Init & Clear LCD, and display Time and Battery Voltage on 1st line, and display Number of Measurements and Measurement Interval on 2nd. */

  int batteryVolts = batteryVoltageOfPP3();
  ClearAndWriteFirstLineToLcdScreen(batteryVolts);

  if (programERROR != NO_ERRORS)
  {
    Serial.print(F("E"));
    Serial.println(int(programERROR));
    WriteErrNumberToFile(programERROR);
  }

  // TEST CODE FOR ERROR SCREENS                    COMMENT THIS OUT EXCEPT FOR WHEN TESTING ERROR DISPLAY XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
  /*
    for (int enumbr = 0; enumbr < 12; enumbr++) {
    DisplayErrorOnLcdScreen(enumbr);
    delay(1000);
    }
    ClearAndWriteFirstLineToLcdScreen(batteryVolts);
  */
  // END TEST CODE FOR ERROR SCREENS                COMMENT DOWN TO HERE EXCEPT FOR WHEN TESTING ERROR DISPLAY XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

  /* IF No programERROR (either from above or previously set) THEN proceed */
  if (programERROR < FIRST_FATAL_ERROR) // Only errors from FIRST_FATAL_ERROR and upwards are fatal. So OK to proceed.
  {
    uint32_t IntervalInSeconds = (unsigned long)(gIntervalInMins) * (unsigned long)(60);

    /* IF alarmFired() THEN set DoAlarmLog TRUE   (note: if Nano is continuously powered from PC, the alarm may have gone off since last time around the loop) */
    if (rtc.alarmFired(1)) {
      DoAlarmLog = true;
    }

    /* Clear the alarm. */
    /* Then work out when the alarm needs to go off to make the next set of measurements and set it.
      (Note we cannot easily be sure that the alarm has previously been set correctly to the desired interval, since we can't easily distinguish
      the first ever power up from subsequent power ups. So we probably need to set it each time). */
    rtc.clearAlarm(1);
    rtc.disableAlarm(1);
    DateTime nextLogTime( getTimeOfNextMeasurement (IntervalInSeconds, LogrCfg.unixTimeSTART) );
    // This creates an alarm when date, hours, minutes, and seconds match. We use a full match in case the start date is days
    // into the future. Each wake up, we set a full match again for the next alarm.
    rtc.setAlarm1(nextLogTime, DS3231_A1_Date);
    WriteLogCountAndNextToScreen(nextLogTime);

    /* Make Measurements ... */
    /* Read Temperatures T1 & T2 and Weight  */

    // Read Weight; Read Temperatures;
    int temperature1 = readTemperatureFromAD(0); // T1 must be used for external temperature (outside hive, just under strain guage sensors, to allow temperatur correction).
    int temperature2 = readTemperatureFromAD(1); // T2 should be used for internal temperature (typically in centre of brood, third way down a frame).
    long Weight = readWeight();
    // long WeightTemperatureCorrected = temperatureAdjustWeight(Weight, temperature1); // Deactivated in version 4.3

    if (DoManualLog) {
      // WRITE a log to next space in ResultsFile, with thisMeasureHour and thisMeasureMinute, plus calendar time stamp if desired.
      // LogType of 'A' means normal Alarm timed log, 'M' means Manual log.
      LogDataToFile(Weight, temperature1, temperature2, batteryVolts, 'M');
      DoManualLog = false;  // We only ever do a manual log once per power-up.
    }


    /* IF DoAlarmLog THEN
      Open ResultsDataFile for Write (creates it doesn't exist, e.g. if this is first measurement)
      If there's a file opening error, set programERROR = ERR_CANT_OPEN_LOG

      WRITE the NEW VALUES to next space in ResultsFile, with the scheduled DateTime of the measurement (it will be to nearest
      minute (Could think about adding exact DateTime if we wanted to nearest second).
      Close the ResultsDataFile

      IF SMS Due THEN      (Send SMS once every N Measurement Intervals.)
        Power UP GSM
        Compose and send SMS containing last (few?) measurements.
      ENDIF
      ENDIF
    */
    if (DoAlarmLog) {
      // WRITE the NEW VALUES to next space in ResultsFile, with thisMeasureHour and thisMeasureMinute, plus calendar time stamp if desired.
      // LogType of 'A' means normal Alarm timed log, 'M' means Manual log.
      LogDataToFile(Weight, temperature1, temperature2, batteryVolts, 'A');

      //  IF SMS Due THEN Power UP GSM,  Compose and send SMS containing last (few?) measurements.
      if (LogrCfg.SMS_On && (nextLogTime <= (LogrCfg.unixTimeEND + IntervalInSeconds) ) ) { // Stops sending SMS after END time passed  
        // Log if whole number of gLogsPerSMS since start. So logs should occur at same HH:MM of the day as the HH:MM specified in START TIME.
        if ( CountOfAutoLogs != 0L   &&    (CountOfAutoLogs - 1L) % ((unsigned long)(gLogsPerSMS))  == 0  ) {
          if (SD.exists(DATA_FILE_NAME)) {
            //Serial.println(F("sendSmsLog"));
            sendSmsLog();
            delay(500);
          } else {
            // Serial.println(F("Err: NoLogFileforSMS"));
            programERROR = ERR_NOLOG_FOR_SMS;
          }
        }
      }
    }
    if (programERROR != NO_ERRORS)  // Record any further error that may have occurred
    {
      WriteErrNumberToFile(programERROR);
    }
  } else {
    // FATAL ERROR CASE. We need to clear and disable alarm, or the unit will never sleep. The unit will not wake up again except by a manual wakeup or when connected to a PC.
    rtc.clearAlarm(1);
    rtc.disableAlarm(1);
    DisplayErrorOnLcdScreen(programERROR); // Display Error information
    delay(5000);  // Show error screen for 5 seconds
  }

  /*
  * *******************************************************************************************************
    ---XXXX   POWER DOWN POINT   XXXX---
    The arduino should power down, provided that Force On switch is OFF and USB cable to PC is not plugged in.
  * *******************************************************************************************************
  */


  /*
    IF WakeUpReason == Alarm THEN
    POWER DOWN (Clear the Power Control FlipFlop by toggling PIN_POWER_ARDUINO).
    ENDIF */
  if (!ManualWakeUp) {  // I.e. Not anymore a ManualWakeUp, because it's been processed and cleared. SayGoodbye will have been set.
    if (SayGoodbye) {   // We SayGoodbye if it was originally a ManualWakeUp. We don't for an auto wake-up, as it's a waste of time.
      //DisplayBannerScreen(Dormiendo_txt, Adios_txt);
      DisplaySectionScreen(UI_SLEEPING);
    }
    PowerDownArduino();  // POWER DOWN!
  }



  /*
  * *******************************************************************************************************
    ---XXXX   NOT SLEEPING   XXXX---
    IF we get here, the arduino did not sleep, because it was a Manual Wake Up or because USB cable to PC is connected.
  * *******************************************************************************************************

    Display current measurements and any other desired status info.
    Or display ERROR information

    Delay several seconds to allow user to read a stable display before we loop back to start of main loop.
    If within margin of a logtime, delay enough to be out of the margin before repeating the loop, to avoid multiple loggings.
    BUT don't delay longer than the LogInterval as that might cause loggings to be missed when we are 'Forced On'.

  */

  /* We only get to here if it was a manual wake up (not alarm) or we are continuously powered

    Do any manual log requested here instead ???

    Display the Measurements UI
    IF No programERRORs
    Display how long until the NextLog, and current Temperature and Weight measurements on the LCD screen
    ELSE
    Display the ERROR number and Error details on the LCD screen
    ENDIF
  */
  delay(5000); // Allow time for user to view last display, put up by WriteLogCountAndNextToScreen().
  ManualWakeUp = false;  // Clear so that next time around the loop we sleep.
  SayGoodbye = true;

  DisplaySectionScreen(UI_MEASUREMENTS);
  if (programERROR == NO_ERRORS) {
    fileHandle = SD.open(DATA_FILE_NAME); // Open the file for read
    unsigned long dataFileLength = fileHandle.size(); // We want this to calculate and display on LCD number of records in file.
    fileHandle.close();
    delay(5);
    DisplayMeasurementsOnLcdScreen(); // Display Next and Current readings
  } else {
    DisplayErrorOnLcdScreen(programERROR); // Display Error information
  }
  delay(5000);  // Show data (or error) screen for 5 seconds


  DisplaySectionScreen(UI_CLOCK);
  OfferToAdjustClock();
  DisplaySectionScreen(UI_SETTINGS);
  OfferViewConfigSettings();
  if (!FIXED_CALIBRATION){
    DisplaySectionScreen(UI_CALIBRATION);
    OfferCalibrateTemperatureSensors();
    OfferCalibrateWeightSensor();
  }
  DisplaySectionScreen(UI_TEST_SMS);
  OfferTestSMS();
}
/* End of MAIN LOOP - this is the end of the program! However the loop will repeat if Nano is powered from PC  */

CONFIG.TXT File - An Example

NOTE: REPLACE +44nnnnnnnnnn with your CountryCode and TelNumber!

PARAM, VALUE, NAME, NOMBRE
A, 2020-11-26 14:00,Start,Inicio
B, 2029-12-31 15:00,End,Fin
C, 12,Every,Cada
D, H,D_H_or_M, D_H_o_M
E, 3,DaysPerSMS,DiasPorSMS
F, Y,SMS_Yes_No,SMS_Si_No
G, +44nnnnnnnnnn,TelNum,TelNum
H, Col1,Hive,Colmena
I, S,Lang,Idioma

UITXT.TXT File

0,UI_YEAR20,"YEAR 20","ANO 20"
1,UI_MONTH,"MONTH ","MES "
2,UI_DAY,"DAY ","DIA "
3,UI_HOUR,"HOUR ","HORA "
4,UI_MINUTE,"MINUTE ","MINUTO "
5,UI_SET_CLOCK,"SET CLOCK?","AJUSTAR RELOJ?"
6,UI_PRESS_CHANGE,"Press to change","Aprieta = cambia"
7,UI_CLOCK,"CLOCK","RELOJ"
8,UI_UNCHANGED,"UNCHANGED","NO AJUSTADO"
9,UI_ADJUSTED,"ADJUSTED","AJUSTADO"
10,UI_SUN,"Sunday","Domingo"
11,UI_MON,"Monday","Lunes"
12,UI_TUES,"Tuesday","Martes"
13,UI_WEDNES,"Wednesday","Miercoles"
14,UI_THURS,"Thursday","Jueves"
15,UI_FRI,"Friday","Viernes"
16,UI_SATUR,"Saturday","Sabado"
17,UI_RTC_RESTART,"RTC restart","Reinicio de RTC"
18,UI_NO_UITXTFILE,"No UITXT file","No UITXT file"
19,UI_SW_ID_MATCH,"SW NoMatch UITXT","SW NoMatch UITXT"
20,UI_CSV_READ_ERR,"CSV read failed","CSV read failed"
21,UI_NOLOG_FOR_SMS,"No LOG for SMS","No LOG for SMS"
22,UI_NO_RTC,"Can't Find RTC","Can't Find RTC"
23,UI_NO_SD_CARD,"No SD Card","Falta tarjeta SD"
24,UI_NO_CONFIGFILE,"No CONFIG file","No CONFIG file"
25,UI_CANT_OPEN_LOG,"Can't Open LOG","Can't Open LOG"
26,UI_FILE_2_SHORT,"File Too Short","File Too Short"
27,UI_BAD_LOG_FILE,"Bad SD LOG file","Bad SD LOG file"
28,UI_UNKNOWN_ERROR,"Unknown Error","Unknown Error"
29,UI_WELCOME,"WELCOME","BIENVENIDO"
30,UI_SLEEPING,"SLEEPING...","DURMIENDO ..."
31,UI_PRESS_FOR_YES,"Press for YES","Aprieta para SI"
32,UI_SENDNG_SMS_TO,"Sending SMS to","Enviando SMS a"
33,UI_VIEW_SETTINGS,"VIEW SETTINGS?","VER CONFIG.?"
34,UI_START,"Start","Inicio"
35,UI_END,"End","Fin"
36,UI_LOG_EVERY,"Log every","Medir cada"
37,UI_SEND_SMS,"Send SMS","Enviar SMS"
38,UI_SMS_EVERY,"SMS every","SMS cada"
39,UI_SENDSMSTO_TEL,"Send SMS to Tel","Enviar SMS a Tel"
40,UI_HIVE_ID,"Hive ID","ID de colmena"
41,UI_SEND_TEST_SMS,"SEND TEST SMS?","PROBAR SMS?"
42,UI_CALIB_TEMP,"CALIBRATE TEMP.?","CALIBRAR TEMP.?"
43,UI_PRESS_RAISES,"Press to RAISE","Aprieta = SUBIR"
44,UI_PRESS_LOWERS,"Press to LOWER","Aprieta = BAJAR"
45,UI_CONF_CHANGE,"CONFIRM CHANGE?","CONFIRMAR CAMB.?"
46,UI_CALIBRATION,"CALIBRATION","CALIBRACION"
47,UI_MEASUREMENTS,"MEASUREMENTS","MEDICIONES"
48,UI_SETTINGS,"SETTINGS","CONFIGURACION"
49,UI_TEST_SMS,"TEST SMS","SMS DE PRUEBA"
50,UI_CALIB_WEIGHT,"CALIB WEIGHT?","CALIBRAR PESO?"
51,UI_REVERT_WEIGHT,"REVERT 2FACTORY?","VOLVER ORIGINAL?"
52,WT_ZERO_CALIB1,"Set ZERO without","Ajustar ZERO sin"
53,WT_ZERO_CALIB2,"hive on stand","colmena encima"
54,UI_SET_ZERO,"Press to ZERO","Aprieta para 0.0"
55,WT_SCALE_CALIB1,"Put a known Wei-","Pon un peso con-"
56,WT_SCALE_CALIB2,"ght and adjust","ocido y ajustar"
57,WT_WAIT,"wait...","espera..."