# DB Weather Data Sync Management
# DB Weather Data Integrity Check
# WeatherDataSync.py
#
# @author Jack Rogerson (jrogerson@clarku.edu) (jrogerson.com)
# @version Spring 2022
# PHYS 243 Spring 2022 - Professor Charles Agosta
#
# Previous Versions:
#
# About:
#   WeatherDataSync.py is a dual-purpose script that can be used for obtaining Weather Flow data
#   observations every minute or integrity checking a period of data points and repairing if necessary.
#
#   Under standardUpdate operation, each call of the script will query Weather Flow data for all of the
#   specified weather stations in the .env file. The data from each station is added to the database.
#   If errors occur, a case-by-case email report will be sent for standardUpdate.
#
#   Under integrityCheck operation, a user-specified number of days are checked for data integrity, meaning that
#   all expected data points are present. If any given data point is missing, it will be queried from
#   Weather Flow and added to the database. If the data is not found in Weather Flow's systems, no immediate
#   email alert will trigger. All other alerts for Weather Flow errors and database integrity violations will result
#   in an email alert. At the conclusion of an integrityCheck operation, a summary report will be emailed if necessary.
#   The integrityCheck operation is resilient to an error within program execution and will attempt to finish
#   the entirety of an integrityCheck even if one operation results in a database integrity violation.
#
#   For additional information, see Weather Flow documentation: Get Observations Device
#   https://weatherflow.github.io/Tempest/api/swagger/#!/observations/getStationObservation

import os
from dotenv import load_dotenv
import mysql.connector
import calendar
import datetime
from datetime import time
import requests
from shlex import quote
import time as timeimport
import argparse


def databaseInsert(microgridDBCursor, microgridDBInstance, stationName, dataDictionary, isIntegrityCheck, emailOtherAlerts):
    """
    databaseInsert inserts the well-formatted dataDictionary passed as a parameter to the DB instance already open.
    databaseInsert does basic checking to ensure expected rows are present. If an error occurs, exception(s) are returned.

    :param microgridDBCursor: The DB cursor. Used to execute SQL.
    :param microgridDBInstance: The DB Instance (Used to commit changes).
    :param stationName: The full DB table name of the station we are attempting to insert a record for.
    :param dataDictionary: The WeatherFlow data dictionary that has been previously prepared. It should be well formatted at this point.
    :param isIntegrityCheck: The isIntegrityCheck flag determines if the verbose program outputs should be saved for a final email report.
    :param emailOtherAlerts: The emailOtherAlerts is the secondary list of components in an integrity check summary report.
    :return: None. The DB process required will be done upon return.
    """
    print("Attempting insert for: " + str(stationName) + " station at " + str(dataDictionary["Time_Block_ID"]))

    if (isIntegrityCheck):                  #If we need to generate an integrity check summary report, add this message.
        emailOtherAlerts.append("Attempting insert for: " + str(stationName) + " station at " + str(dataDictionary["Time_Block_ID"]))       #Add to the report messages.

    #SQL INSERT statement to insert the sanitized Weather Flow data.
    microgridDBCursor.execute(
        "INSERT INTO " + stationName + " ("
                                       "Time_Block_ID,"
                                       "Wind_Lull_Meters_Per_Second,"
                                       "Wind_Average_Meters_Per_Second,"
                                       "Wind_Gust_Meters_Per_Second,"
                                       "Wind_Direction_Degrees,"
                                       "Wind_Sample_Interval_Seconds,"
                                       "Pressure_Millibar,"
                                       "Air_Temperature_Celsius,"
                                       "Relative_Humidity_Percentage,"
                                       "Illuminance_Luminous_Intensity,"
                                       "Ultraviolet_Index,"
                                       "Solar_Radiation_Watts_Per_Square_Meter,"
                                       "Rain_Accumulation_Millimeters,"
                                       "Precipitation_Type,"
                                       "Average_Strike_Distance_Kilometer,"
                                       "Strike_Count,"
                                       "Internal_Battery_Volts,"
                                       "Report_Interval_Minutes,"
                                       "Local_Day_Rain_Accumulation_Minutes,"
                                       "Nearcast_Rain_Accumulation_Millimeters,"
                                       "Local_Day_Nearcast_Rain_Accumulation_Millimeters,"
                                       "Precipitation_Analysis,"
                                       "Device_ID,"
                                       "Device_Type,"
                                       "Status"
                                       ") VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, "
                                       "%s, %s, %s, %s, %s, %s, %s, %s)",
        [dataDictionary["Time_Block_ID"],
         dataDictionary["Wind_Lull_Meters_Per_Second"],
         dataDictionary["Wind_Average_Meters_Per_Second"],
         dataDictionary["Wind_Gust_Meters_Per_Second"],
         dataDictionary["Wind_Direction_Degrees"],
         dataDictionary["Wind_Sample_Interval_Seconds"],
         dataDictionary["Pressure_Millibar"],
         dataDictionary["Air_Temperature_Celsius"],
         dataDictionary["Relative_Humidity_Percentage"],
         dataDictionary["Illuminance_Luminous_Intensity"],
         dataDictionary["Ultraviolet_Index"],
         dataDictionary["Solar_Radiation_Watts_Per_Square_Meter"],
         dataDictionary["Rain_Accumulation_Millimeters"],
         dataDictionary["Precipitation_Type"],
         dataDictionary["Average_Strike_Distance_Kilometer"],
         dataDictionary["Strike_Count"],
         dataDictionary["Internal_Battery_Volts"],
         dataDictionary["Report_Interval_Minutes"],
         dataDictionary["Local_Day_Rain_Accumulation_Minutes"],
         dataDictionary["Nearcast_Rain_Accumulation_Millimeters"],
         dataDictionary["Local_Day_Nearcast_Rain_Accumulation_Millimeters"],
         dataDictionary["Precipitation_Analysis"],
         dataDictionary["Device_ID"],
         dataDictionary["Device_Type"],
         str(dataDictionary["Status"])]
    )

    #Determine if operations are successful:
    if (microgridDBCursor.rowcount == 1):                   #Quick sanity check to make sure we have all expected rows.
        microgridDBInstance.commit()                        #At this point, safe to commit new data point.
        print(" ---> Operation was successful. Added row!")
        if (isIntegrityCheck):                                  #If we need to generate an integrity check summary report, add this message.
            emailOtherAlerts.append(" ---> Operation was successful. Added row!")
    else:
        microgridDBInstance.rollback()                                  #Something is not right, rollback and fail.
        print("All expected DBCursor rows are not present! DB has not been modified!")
        if (isIntegrityCheck):                                  #If we need to generate an integrity check summary report, add this message.
            emailOtherAlerts.append("All expected DBCursor rows are not present! DB has not been modified!")
        raise Exception(
            "All expected DBCursor rows are not present! DB has not been modified!")            #Exit. Raise Exception.

    return


def createDataDictionaryFromWeatherFlow(weatherFlowData, weatherFlowQueryTime):
    """
    createDataDictionaryFromWeatherFlow is responsible for parsing JSON from WeatherFlow and creating a dataDictionary.
    createDataDictionaryFromWeatherFlow also checks the response from WeatherFlow to ensure data is present and well-formatted.
    If WeatherFlow changes the structure of data returned, this would be the place to start. Some data is required, and some can be NULL.

    :param weatherFlowData: Raw JSON data returned from WeatherFlow.
    :param weatherFlowQueryTime: The Unix timecode for the query that was executed. (Used for error messages).
    :return: The well-formatted and complete dataDictionary ready for insertion into the DB.
    """

    #If weatherFlow data does not exist for a QueryTime, the observation is null/none.
    if (weatherFlowData["obs"] == None):
        print("WeatherFlow data at the requested time does not exist! (obs is NULL). Time attempted: " + str(weatherFlowQueryTime))
        raise Exception("WeatherFlow data at the requested time does not exist! (obs is NULL). Time attempted: " + str(weatherFlowQueryTime))   #Raise Exception. Process Continues.

    weatherFlowDataOBSReadings = weatherFlowData["obs"][0]          #We know WeatherFlow was not empty at this point.

    dataColumns = ["Time_Block_ID", "Wind_Lull_Meters_Per_Second", "Wind_Average_Meters_Per_Second",
                   "Wind_Gust_Meters_Per_Second", "Wind_Direction_Degrees", "Wind_Sample_Interval_Seconds",
                   "Pressure_Millibar", "Air_Temperature_Celsius", "Relative_Humidity_Percentage",
                   "Illuminance_Luminous_Intensity", "Ultraviolet_Index", "Solar_Radiation_Watts_Per_Square_Meter",
                   "Rain_Accumulation_Millimeters", "Precipitation_Type", "Average_Strike_Distance_Kilometer",
                   "Strike_Count", "Internal_Battery_Volts", "Report_Interval_Minutes",
                   "Local_Day_Rain_Accumulation_Minutes", "Nearcast_Rain_Accumulation_Millimeters",
                   "Local_Day_Nearcast_Rain_Accumulation_Millimeters", "Precipitation_Analysis"]

    #Note: Most data points are required to be returned by WeatherFlow. Nearcast data points can be a null value at times.
    # --->>> In the database, the Nearcast data is allowed to be NULL. If other data points are not present, they will reject.
    dataDictionary = {}                                     #Establish the empty data dictionary for WeatherFlow to be saved to.
    for dataPoint in dataColumns:
        indexArr = dataColumns.index(dataPoint)                             #Get the numerical index for the WeatherFlow API response.
        dataDictionary[dataPoint] = weatherFlowDataOBSReadings[indexArr]        #Save the WeatherFlow data to the dataDictionary.

    #Dictionaries are mutable objects. fixFormatWeatherFlowCases updates the dictionary passed as a parameter.
    fixFormatWeatherFlowCases(dataDictionary)                   #Fix specific case formatting of Weather Flow Data. Edit's in place.

    #Add metadata information regarding datapoint.
    dataDictionary["Device_ID"] = weatherFlowData["device_id"]      #The Weather Flow ID for the weather device.
    dataDictionary["Device_Type"] = weatherFlowData["type"]         #The type of observations from the device.
    dataDictionary["Status"] = weatherFlowData["status"]            #The API HTTP response code(s).

    return dataDictionary               #Return the well-formatted Weather Flow data dataDictionary.


def fixFormatWeatherFlowCases(dataDictionary):
    """
    fixFormatWeatherFlowCases edits the mutable dataDictionary to add string values for WeatherFlow data.
    Some data from WeatherFlow is provided as integers that represent preestablished strings.

    :param dataDictionary: Reference to dataDictionary created from WeatherFlow data.
    :return: None. Modifies WeatherFlow dataDictionary in place.
    """
    #For Precipitation_Type
    if dataDictionary["Precipitation_Type"] == 0:
        dataDictionary["Precipitation_Type"] = "none"

    elif dataDictionary["Precipitation_Type"] == 1:
        dataDictionary["Precipitation_Type"] = "rain"

    elif dataDictionary["Precipitation_Type"] == 2:
        dataDictionary["Precipitation_Type"] = "hail"

    elif dataDictionary["Precipitation_Type"] == 3:
        dataDictionary["Precipitation_Type"] = "rain & hail"

    #For Precipitation_Analysis
    if dataDictionary["Precipitation_Analysis"] == 0:
        dataDictionary["Precipitation_Analysis"] = "none"

    elif dataDictionary["Precipitation_Analysis"] == 1:
        dataDictionary["Precipitation_Analysis"] = "Rain Check with user display on"

    elif dataDictionary["Precipitation_Analysis"] == 2:
        dataDictionary["Precipitation_Analysis"] = "Rain Check with user display off"

    return


def queryDataAndInsertToDatabase(microgridDBCursor, weatherFlowDeviceIDs, weatherFlowClarkNames, weatherFlowQueryURL, deviceID, weatherFlowQueryTime, isIntegrityCheck, emailNULLDataErrors, emailOtherAlerts):
    """
    queryDataAndInsertToDatabase starts by making a request to weatherFlow for the data at the given QueryTime.
    Next, queryDataAndInsertToDatabase cleans the received data via createDataDictionaryFromWeatherFlow.
    Finally, queryDataAndInsertToDatabase inserts data to the DB via databaseInsert.
    If exception(s) occur, they will be raised and displayed.

    :param microgridDBCursor: The DB cursor. Used to execute SQL.
    :param weatherFlowDeviceIDs: The array of weatherFlow Device IDs listed in the .env file.
    :param weatherFlowClarkNames: The array of weatherFlow Clark names listed in the .env file.
    :param weatherFlowQueryURL: The query URL that will be called for weatherFlow data. Listed in the .env file.
    :param deviceID: The deviceID of the device that will be requested for this iteration.
    :param weatherFlowQueryTime: The Unix timecode of the Time_Block_ID be requested for this iteration.
    :param isIntegrityCheck: The isIntegrityCheck flag determines if the verbose program outputs should be saved for a final email report.
    :param emailNULLDataErrors: The emailNULLDataErrors is the primary list of critical errors in an integrity check summary report.
    :param emailOtherAlerts: The emailOtherAlerts is the secondary list of components in an integrity check summary report.
    :return: None. The weatherFlow requests and DB process required will be done upon return.
    """
    stationName = None              #Establish empty values in case of failure and required email report. See except.
    weatherFlowData = None          #Establish empty values in case of failure and required email report. See except.
    dataDictionary = None           #Establish empty values in case of failure and required email report. See except.

    try:
        indexArr = weatherFlowDeviceIDs.index(deviceID)                     #Get the index into the parallel arrays.
        stationName = "Weather_Station_" + weatherFlowClarkNames[indexArr]  #Create expected string for DB table.

        weatherFlowQueryURLThisDevice = weatherFlowQueryURL + deviceID          #The URL for the WeatherFlow Device.
        queryParams = dict(
            time_start = weatherFlowQueryTime,            #Given we only want the one data point, start and end at the same moment.
            time_end = weatherFlowQueryTime,              #Same as above, these ranges are endpoint inclusive.
            token = os.environ.get('WEATHERFLOWTOKEN'),   #From .env File.
        )
        weatherFlowQueryResponse = requests.get(url = weatherFlowQueryURLThisDevice, params = queryParams)      #Send GET.
        weatherFlowData = weatherFlowQueryResponse.json()                       #Turn GET request response to JSON.

        dataDictionary = createDataDictionaryFromWeatherFlow(weatherFlowData, weatherFlowQueryTime)     #Convert to a dataDictionary.
        databaseInsert(microgridDBCursor, microgridDBInstance, stationName, dataDictionary, isIntegrityCheck, emailOtherAlerts)             #Do the DB insert of data.


    except Exception as errorMessage:
        print("A database/Weather Flow error has occurred. Please review error message below. Process will now continue!")
        print("Response from Weather Flow: " + str(weatherFlowData))
        print("Error from Try: " + str(errorMessage))

        #Upon a critical error, send an email message with additional information.
        emailDetails = ("stationName: " + str(stationName) + "\n\n" + "weatherFlowData: " + str(weatherFlowData) + "\n\n" + "dataDictionary: " + str(dataDictionary))           #Construct email body.
        sendEmailAlert(str(errorMessage), str(emailDetails), isIntegrityCheck)          #Send email at the OS level.
        if (isIntegrityCheck):                                                          #If we need to generate an integrity check summary report, add this message.
            emailNULLDataErrors.append(str(errorMessage) + "\n\n" + str(emailDetails))  #Add to the report messages.

    return              #At this point, the data has been inserted, or an error has occurred.


def standardUpdate(microgridDBCursor, weatherFlowDeviceIDs, weatherFlowClarkNames, weatherFlowQueryURL):
    """
    A standardUpdate is designed to run every minute. When run, the data from the previous minute that just
    was received by Weather Flow is added to the database. A standardUpdate should only be run once per minute, per device.

    Any errors that occur in a standardUpdate will immediately be sent via an email report.

    :param microgridDBCursor: The DB cursor. Used to execute SQL.
    :param weatherFlowDeviceIDs: The array of weatherFlow Device IDs listed in the .env file.
    :param weatherFlowClarkNames: The array of weatherFlow Clark names listed in the .env file.
    :param weatherFlowQueryURL: The query URL that will be called for weatherFlow data. Listed in the .env file.
    :return: None. The weatherFlow requests and DB process required will be done upon return.
    """
    dateTimeNow = datetime.datetime.now()                                       #Identify the current time.
    dateTimeNow = dateTimeNow.replace(second = 0)                               #Get the time at the start of this min.

    dateTimeDataSave = dateTimeNow - datetime.timedelta(minutes = 1)        #We are saving the data point at the last min.
    utcTimeDataSave = calendar.timegm(dateTimeDataSave.utctimetuple())      #This is the TimeBlock that we need to save to.

    #Weatherflow keeps the data as eastern time. We need to query them for the time-corrected value.
    #The data becomes available for weatherFlow review after 1 minute. After the minute has concluded, we add the data.
    dateTimePlus4Hour = dateTimeNow + datetime.timedelta(hours = 3, minutes = 59)         #Get the time in '4' hours.
    weatherFlowQueryTime = calendar.timegm(dateTimePlus4Hour.utctimetuple())    #This is the time for Weather Flow data.

    #Note: Do NOT include duplicate DeviceIDs! This will result in a DB integrity violation and program error.
    for deviceID in weatherFlowDeviceIDs:               #For each Weather Flow device in the .env file.
        #Note that we need to statically set information that this is not an integrityCheck process in the call below.
        queryDataAndInsertToDatabase(microgridDBCursor, weatherFlowDeviceIDs, weatherFlowClarkNames, weatherFlowQueryURL, deviceID, weatherFlowQueryTime, False, None, None)

    print("Close DB Connection.")
    microgridDBInstance.close()             #Close and exit DB connection.

    return                                  #WeatherFlow requests and DB process required will be done upon return.


def integrityCheck(microgridDBCursor, weatherFlowDeviceIDs, weatherFlowClarkNames, weatherFlowQueryURL):
    """
    An integrityCheck is a once-daily check for the existence of expected Weather Flow data for a specified
    number of historical days. If the Weather Flow data at a specific timeblock is found to be missing,
    integrityCheck will attempt to query Weather Flow and add the data to the database.

    Note that even if Weather Flow data is missing at the time of a standardUpdate, if network or power issues
    are occurring with the data collection equipment, the Weather Flow Weather Station can store data for 1 hour
    and the Weather Flow Central Hub for one week. Upon successful power/network connectivity, this data is
    uploaded to Weather Flow and can then be successfully queried.

    Any critical database integrity or non-404 Weather Flow errors will be sent via email notification immediately.
    If data is found to be null at the time of an integrityCheck, no instant notification will occur.
    Instead, an integrityCheck summary report will be sent detailing all null data times identified.

    :param microgridDBCursor: The DB cursor. Used to execute SQL.
    :param weatherFlowDeviceIDs: The array of weatherFlow Device IDs listed in the .env file.
    :param weatherFlowClarkNames: The array of weatherFlow Clark names listed in the .env file.
    :param weatherFlowQueryURL: The query URL that will be called for weatherFlow data. Listed in the .env file.
    :return: None. The weatherFlow requests and DB process required will be done upon return.
    """
    print("Performing Integrity Check For All WeatherFlow Stations.")

    emailNULLDataErrors = []                    #The primary list of critical errors in an integrity check summary report.
    emailOtherAlerts = []                       #The secondary list of components in an integrity check summary report.
    isIntegrityCheck = True                     #Flag used to establish summary report email verbosity.

    numberOfDaysToCheck = int(os.environ.get('NUMBEROFDAYSTOCHECK'))                #Number of days that will be checked. From .env File.
    dateTimeNow = datetime.datetime.now()                                           #Identify the current time.
    dateTimeMin30 = dateTimeNow + datetime.timedelta(days = - numberOfDaysToCheck)  #Calculate time delta points for number of days.
    dateTimeStartOfDayMin30 = dateTimeMin30.combine(dateTimeMin30, time.min)        #Calculate the earliest moment of day.
    dateTimeStartOfDayMin30GMTAdj = dateTimeStartOfDayMin30 + datetime.timedelta(hours = 4)   #Adjust for GMT -4 Offset.

    #Note: Do NOT include duplicate DeviceIDs! This will result in a DB integrity violation and program error.
    for deviceID in weatherFlowDeviceIDs:
        try:
            indexArr = weatherFlowDeviceIDs.index(deviceID)                     #Get the index into the parallel arrays.
            stationName = "Weather_Station_" + weatherFlowClarkNames[indexArr]  #Get the station name table name for DB.

            for timeOffset in range(1440 * numberOfDaysToCheck):    #1440 minutes per day. Range is endpoint exclusive, but we want 1439 * number days.
                timeBlockMinute = dateTimeStartOfDayMin30GMTAdj + datetime.timedelta(minutes = timeOffset)  #Calculate time delta points for days.
                utcTimeTimeBlockMinute = calendar.timegm(timeBlockMinute.utctimetuple())  #Get UTC of calculated timeBlockMinute.

                print("Checking weatherFlow data existence For: " + stationName + " at " + str(timeBlockMinute) + " GMT ---> " + str(utcTimeTimeBlockMinute))

                #Check the DB for the Time_Block_ID in question.
                microgridDBCursor.execute("SELECT count(*) FROM " + stationName + " WHERE Time_Block_ID = %s", [utcTimeTimeBlockMinute])  #Note: Be warned this is a tuple data type. This is why we index 0.
                numberOfRows = microgridDBCursor.fetchone()[0]      #Assuming the block ID is here, this should always and only be 1.

                if (numberOfRows != 1):                         #If the Weather Flow data is missing at the requested Time_Block_ID.
                    print("WeatherFlow data for " + stationName + " is missing at: " + str(utcTimeTimeBlockMinute))
                    emailOtherAlerts.append("WeatherFlow data for " + stationName + " is missing at: " + str(utcTimeTimeBlockMinute))           #Add to the report messages.
                    weatherFlowQueryTime = utcTimeTimeBlockMinute                           #This time block is missing. This is now the Query Time for Weather Flow.

                    timeimport.sleep(0.25)          #A rapid succession of requests to the Weather Flow data APIs causes a 429 HTTP Error. Spacing out queries prevents this.

                    queryDataAndInsertToDatabase(microgridDBCursor, weatherFlowDeviceIDs, weatherFlowClarkNames, weatherFlowQueryURL, deviceID, weatherFlowQueryTime, isIntegrityCheck, emailNULLDataErrors, emailOtherAlerts)      #Run Weather Flow and DB processes.

        except Exception as errorMessage:
            print("A database/Weather Flow error has occurred. Please review error message below. Process will now continue!")
            print("Error from Try: " + str(errorMessage))

            sendEmailAlert(str(errorMessage), str(""), isIntegrityCheck)        #Send email at the OS level.
            continue

    #Email report summary logistics.
    if (emailNULLDataErrors != [] or emailOtherAlerts != []):       #If the program messages are not null, proceed with sending the email.
        emailAdditionalInformation = " ~ ~ ~ ~ The following errors are non-recoverable at this time!! ~ ~ ~ ~\n\n\n"

        for reportItem in emailNULLDataErrors:          #Add each reportItem from emailNULLDataErrors that has been collected during program execution.
            emailAdditionalInformation = emailAdditionalInformation + reportItem + "\n\n"
            emailAdditionalInformation = emailAdditionalInformation + "---\n\n"

        emailAdditionalInformation = emailAdditionalInformation + "\n\n\n\n***** ***** *****\n\n\n\n"
        emailAdditionalInformation = emailAdditionalInformation + "The following corrective actions were attempted during this integrity check.\n\n"

        for reportItem in emailOtherAlerts:             #Add each reportItem from emailOtherAlerts that has been collected during program execution.
            emailAdditionalInformation = emailAdditionalInformation + reportItem + "\n"

        sendEmailAlert("Integrity Check Summary Report", emailAdditionalInformation, isIntegrityCheck)      #Call sendEmailAlert for remaining email processes.
    #END: Email report summary logistics.

    print("Integrity Check Complete!")
    return                                  #WeatherFlow requests and DB process required will be done upon return.


def sendEmailAlert(programError, additionalInformation, isIntegrityCheck):
    """
    sendEmailAlert leverages the Linux OS mail command to send SMTP email messages. Depending on
    information and conditions passed as parameters, sendEmailAlert determines if an email alert should be sent.

    As a result of long email messages, a temporary email.temp file will be saved in the directory this script runs.
    The contents of this file are then sent via Linux command cat to the mail operation.
    Additionally, one should use caution when introducing whitespace into the mail command.
    Most mail parameters are sanitized using the quote() function.

    Note that while this function will complete all necessary components of sending an email, it is still necessary to
    ensure the operating system mail configuration is properly set up. One should ensure that postfix has
    been configured properly and test mail functionality.

    :param programError: The primary error resulting in email communication.
    :param additionalInformation: Additional information that will be sent in the email message within the email body.
    :param isIntegrityCheck: A flag to disable certain conditions of 404 error emails during integrity checks.
    :return: None. At return, if needed, the email notification will be sent at the OS level.
    """

    if (os.environ.get('EMAILENABLED')):                                    #If email functionality is enabled in the .env file.
        if (not (isIntegrityCheck and "(obs is NULL)" in programError)):        #Prevent individual emails from Weather Flow 404 HTTP errors during integrity check.

            subject = quote(os.environ.get('EMAILSUBJECT'))                                     #Get the subject from the .env file.
            body = str(os.environ.get('EMAILBODY') + "\n \n \n Program Error Message: \n \n \n" + str(programError) + " \n \n \n Additional Information: \n \n \n" + str(additionalInformation))        #Construct email body.
            sender = os.environ.get('EMAILSENDER')                                              #Get the sender from the .env file.
            recipientsCSV = quote(os.environ.get('EMAILRECIPIENTSCSV'))                         #Get all recipients, by CSV, from the .env file.

            #As a result of excessively long email messages, write data to the filesystem first.
            emailTemporaryFilename = "email-isIntegrityCheck-"+ str(isIntegrityCheck) +".temp"  #WeatherDataSync integrityCheck and WeatherDataSync standardUpdate can run at the same time. We need separate email files.
            with open(emailTemporaryFilename, "w") as tempFile:                                 #Open the temporary email file for writing. Write at the start of the file and erase all previous content.
                tempFile.write(body)                                                            #Write email content.
            tempFile.close()                                                                    #Close OS file with email content.
            os.system("cat " + emailTemporaryFilename + " | mail -s " + subject + " -aFrom:" + sender + " " + recipientsCSV)        #Send the email using mail at the OS level.

            print("Email Notification Sent.")

    return


def parseArgs():
    """
    parseArgs parses the CLI arguments provided when the script was called.

    :return: Parsed arguments.
    """

    parser = argparse.ArgumentParser(description = "Weather Flow Weather Station Data Ingestion System")
    parser.add_argument("operation")

    return parser.parse_args()


if __name__ == "__main__":

    load_dotenv()                                                   #Load the DB info/credentials from the .env file.
    argsCLI = parseArgs()                                              #Parse CLI arguments.

    microgridDBInstance = mysql.connector.connect(                      #Login to the database.
      host = os.environ.get('DBHOST'),                                        #From .env File.
      user = os.environ.get('DBUSER'),                                        #From .env File.
      password = os.environ.get('DBPASSWORD'),                                #From .env File.
      database = os.environ.get('DBDATABASENAME')                             #From .env File.
    )

    microgridDBCursor = microgridDBInstance.cursor()                    #Establish DB cursor to execute commands.

    weatherFlowDeviceIDs = os.environ.get('WEATHERFLOWDEVICEIDS').split(',')                #From .env File.
    weatherFlowClarkNames = os.environ.get('WEATHERFLOWCLARKNAMES').split(',')              #From .env File.
    weatherFlowQueryURL = os.environ.get('WEATHERFLOWQUERYURL')                             #From .env File.

    print("Weather Flow Device IDs: " + str(weatherFlowDeviceIDs))          #Display Weather Flow Device IDs.
    print("Weather Flow Clark Names: " + str(weatherFlowClarkNames))        #Display Weather Flow Clark Names.

    #Select the operation per the CLI argument.
    if argsCLI.operation == "standardUpdate":
        standardUpdate(microgridDBCursor, weatherFlowDeviceIDs, weatherFlowClarkNames, weatherFlowQueryURL)
    elif argsCLI.operation == "integrityCheck":
        integrityCheck(microgridDBCursor, weatherFlowDeviceIDs, weatherFlowClarkNames, weatherFlowQueryURL)
    else:
        print("Invalid Operation CLI Argument!")
        raise Exception("Invalid Operation CLI Argument!")

    if (os.environ.get('EMAILENABLED') and argsCLI.operation == "integrityCheck"):        #If email enabled and integrityCheck operation, send email to confirm script finished successfully.
        sender = os.environ.get('EMAILSENDER')                                  #Get the sender from the .env file.
        subject = quote("WeatherDataSync.py integrityCheck Successful!")        #Set a custom subject.
        recipientsCSV = quote(os.environ.get('EMAILRECIPIENTSCSV'))             #Get all recipients, by CSV, from the .env file.
        os.system("echo " + "WeatherDataSync.py integrityCheck exit 0" + " | mail -s " + subject + " -aFrom:" + sender + " " + recipientsCSV)     #Send the email using mail at the OS level.

    exit(0)                                                     #Clean exit. All set here.
