# STELLA-1 instrument code v6.1
# Science and Technology Education for Land/ Life Assessment
# Paul Mirel 2022-11-16

# import system libraries
import gc #garbage collection, RAM management
gc.collect()
print("start memory free {} kB".format( gc.mem_free()/1000 ))
last_alloc = gc.mem_alloc()/1000.0
import os
import sys
import board
import microcontroller
import time
import rtc
import busio
import digitalio
import terminalio
import storage
import sdcardio
from analogio import AnalogIn

# import display libraries
import displayio
import vectorio # for shapes
import adafruit_ili9341 # TFT (thin film transistor) display
from adafruit_display_text.bitmap_label import Label
from adafruit_display_text import label
from adafruit_display_shapes.rect import Rect
from adafruit_display_shapes.circle import Circle
#from adafruit_display_shapes.triangle import Triangle
#from adafruit_display_shapes.line import Line
from adafruit_stmpe610 import Adafruit_STMPE610_SPI # touch screen reader

# import device specific libraries
import adafruit_mlx90614    # thermal infrared
import adafruit_mcp9808     # air temperature
import adafruit_as726x      # visible spectrum
import adafruit_pcf8523     # real time hardware_clock
from adafruit_bme280 import basic as adafruit_bme280 # weather

# set constants
UID = int.from_bytes(microcontroller.cpu.uid, "big") % 10000
print( "UID:", UID )
LOW_BATTERY_VOLTAGE = 3.7
SCREENSIZE_X = const(320)
SCREENSIZE_Y = const(240)
DATA_FILE = "/sd/data.txt"
VIS_BANDS = ( 450, 500, 550, 570, 600, 650 ) # from amd as7262 datasheet
VIS_BAND_PREFIXES = ( "V", "B",  "G", "Y", "O", "R" )
NIR_BANDS = ( 610, 680, 730, 760, 810, 860 ) # from amd as7263 datasheet
ON = const(0)  #sample_indicator is active low.
OFF = const(1)

def main():
    # set desired interval between samples
    desired_sample_interval_s = 1.0 # takes ~0.8 to read and record data
    if desired_sample_interval_s < 0.9:
        desired_sample_interval_s = 0.9 # minimum value for STELLA-1
        # Values less than the loop time cause negative wait times,
        # which skips the wait loop where the inputs get interpreted,
        # so the system not longer responds to inputs.

    gc.collect()
    # initialize busses
    spi_bus = initialize_spi()
    i2c_bus = initialize_i2c()
    uart_bus = initialize_uart()

    # get hardware real time clock and use it as the source
    # for the microcontroller real time clock
    hardware_clock = initialize_real_time_clock( i2c_bus )
    system_clock = rtc.RTC()
    system_clock.datetime = hardware_clock.datetime
    #timenow = hardware_clock.datetime
    #print(timenow)  # hardware clock time
    #feather_local_time = time.localtime()
    #print(feather_local_time)

    # initialize display
    display = initialize_display( spi_bus )
    display_group_table = initialize_display_group( display )
    text_group = create_table_screen( display, display_group_table )

    gc.collect()

    welcome_group = create_welcome_screen( display )
    display.show( welcome_group )
    gc.collect()

    #initializing hardware devices
    # the initialize_x() functions return false if they fail
    # But, do not try to test with "if not some_sensor", use "if some_sensor==False"
    # because you might get False if the object has a len or or bool function (i.e. a list like object)
    sd_card = initialize_sd_card( spi_bus )
    battery_monitor = initialize_battery_monitor()
    power_indicator = initialize_power_indicator()      #active high
    sample_indicator = initialize_sample_indicator()    #active low
    TIR_sensor = initialize_TIR_sensor( i2c_bus )
    AT_sensor = initialize_AT_sensor( i2c_bus )
    WX_sensor = initialize_WX_sensor( i2c_bus )
    VIS_sensor = initialize_VIS_sensor( i2c_bus )
    NIR_sensor = initialize_NIR_sensor( uart_bus )
    aux_lamp = initialize_auxilliary_lamp()
    #hardware devices initialization completed

    set_power_indicator( power_indicator, True )

    # initialize inputs
    touch_screen = initialize_touch_screen( spi_bus )
    pushbutton = initialize_pushbutton()

    number_of_record_pause_states = 2 # record_pause_state 0 means the system is recording, 1 means the system is paused
    number_of_source_lamps_states = 2
    number_of_display_states = 1

    record_pause_state = 0
    last_record_pause_state = 0
    screen_record_pause_press_state = True
    screen_record_pause_last_press_state = False
    pushbutton_press_state = False
    pushbutton_last_press_state = False
    press = [0,0,0]

    source_lamps_state = 0
    last_source_lamps_state = 0
    source_lamp_press_state = False
    source_lamps_last_press_state = False

    display_state = 0
    last_display_state = 3 #startup

    show_record_pause_icon( display_group_table, record_pause_state, 0, 0)

    batch_start_s = time.monotonic()
    batch_label = update_batch( read_clock( hardware_clock ) )
    startup = True
    inputs = check_inputs( pushbutton, touch_screen, pushbutton_last_press_state, screen_record_pause_last_press_state, source_lamps_last_press_state )

    while True:
        last_sample_time_s = time.monotonic()

        if record_pause_state == 0:
            gc.collect()
            #report_memory( "recording..." )#, display_state == {}".format (display_state) )
            if inputs[0] or startup:
                inputs = check_inputs( pushbutton, touch_screen, pushbutton_last_press_state, screen_record_pause_last_press_state, source_lamps_last_press_state )
                #print( "record_pause_state = 0, which means it's recording, so make measurements")
                last_alloc = gc.mem_alloc()/1000.0
                timestamp = read_clock( hardware_clock )
                if last_record_pause_state == 1:
                    batch_start_s = time.monotonic()
                    batch_label = update_batch( timestamp )
                    last_record_pause_state = 0
                iso8601_utc = "{:04}{:02}{:02}T{:02}{:02}{:02}Z".format( timestamp.tm_year, timestamp.tm_mon, timestamp.tm_mday, timestamp.tm_hour, timestamp.tm_min, timestamp.tm_sec)
                #format( iso8601_utc )
                decimal_hour = timestamp_to_decimal_hour( timestamp )
                weekday = timestamp_to_weekday( timestamp )
            if inputs[0] or startup:
                inputs = check_inputs( pushbutton, touch_screen, pushbutton_last_press_state, screen_record_pause_last_press_state, source_lamps_last_press_state )
                #read the devices, from main(), since we already initialised the devices by object name
                surface_temperature_C = read_TIR_sensor( TIR_sensor )
                air_temperature_C = read_AT_sensor( AT_sensor )
                humidity_relative_percent = read_WX_humidity( WX_sensor )
                pressure_hPa = read_WX_pressure( WX_sensor )
                altitude_m = read_WX_altitude( WX_sensor )
                # This is slow: ~0.09 seconds to read WX
            if inputs[0] or startup:
                inputs = check_inputs( pushbutton, touch_screen, pushbutton_last_press_state, screen_record_pause_last_press_state, source_lamps_last_press_state )
                visible_spectrum_uW_per_cm_squared = [0,0,0,0,0,0]
                visible_spectrum_uW_per_cm_squared = read_VIS_spectrum( VIS_sensor )
                nir_spectrum_uW_per_cm_squared = [0,0,0,0,0,0]
                nir_spectrum_uW_per_cm_squared = read_NIR_sensor( NIR_sensor )
                battery_voltage = check_battery_voltage( battery_monitor )
                if battery_voltage < LOW_BATTERY_VOLTAGE:
                    low_battery_voltage_notification( power_indicator, text_group )

            if inputs[0] or startup:
                inputs = check_inputs( pushbutton, touch_screen, pushbutton_last_press_state, screen_record_pause_last_press_state, source_lamps_last_press_state )
                datapoint = [] #reset datapoint string
                #datapoint.append( "UID:" )
                datapoint.append( UID )
                datapoint.append( batch_label )
                datapoint.append( "{:},{:},hh.hhhh,{:}".format(weekday, iso8601_utc, decimal_hour ) )
                # name,units,value,calibration_error
                datapoint.append( "surface_temperature,C,{:},1.0".format( surface_temperature_C ) )
                datapoint.append( "air_temperature,C,{:},0.3".format( air_temperature_C ) )
                datapoint.append( "relative_humidity,%,{:},3".format(  humidity_relative_percent ) )
                datapoint.append( "air_pressure,hPa,{:},1".format( pressure_hPa ) )
                datapoint.append( "altitude_uncalibrated,m,{:},100".format( altitude_m ) )
                # spectrum, wavelength-units, irradiance-units, bandlabel, irradiance, cal-error
                datapoint.append( "visible_spectrum,nm,uW/cm^2,12/100")
                # VIS_BANDS have a color initial prefix
                for i,band in enumerate( VIS_BANDS ):
                    datapoint.append( band )
                    datapoint.append( visible_spectrum_uW_per_cm_squared[ i ] )
                datapoint.append( "near_infrared_spectrum,nm,uW/cm^2,12/100")
                for i,band in enumerate( NIR_BANDS ):
                    datapoint.append( band )
                    datapoint.append( nir_spectrum_uW_per_cm_squared[ i ] )
                datapoint.append( "battery_voltage,V,{:}".format( battery_voltage ) )
            if inputs[0] or startup:
                inputs = check_inputs( pushbutton, touch_screen, pushbutton_last_press_state, screen_record_pause_last_press_state, source_lamps_last_press_state )
                # write to file
                write_line_success = write_line_to_file( datapoint, sample_indicator )

                #print()
                mirror_data = "mirror_data, {}, {}".format( UID, batch_label )
                mirror_data = ( mirror_data
                    + ", " + weekday
                    + ", " + str( timestamp.tm_year )
                    + ", " + str( timestamp.tm_mon )
                    + ", " + str( timestamp.tm_mday )
                    + ", " + str( timestamp.tm_hour )
                    + ", " + str( timestamp.tm_min )
                    + ", " + str( timestamp.tm_sec )
                    + ", " + str( decimal_hour )
                    + ", " + str( visible_spectrum_uW_per_cm_squared[ 0 ] )
                    + ", " + str( visible_spectrum_uW_per_cm_squared[ 1 ] )
                    + ", " + str( visible_spectrum_uW_per_cm_squared[ 2 ] )
                    + ", " + str( visible_spectrum_uW_per_cm_squared[ 3 ] )
                    + ", " + str( visible_spectrum_uW_per_cm_squared[ 4 ] )
                    + ", " + str( visible_spectrum_uW_per_cm_squared[ 5 ] )
                    + ", " + str( nir_spectrum_uW_per_cm_squared[ 0 ] )
                    + ", " + str( nir_spectrum_uW_per_cm_squared[ 1 ] )
                    + ", " + str( nir_spectrum_uW_per_cm_squared[ 2 ] )
                    + ", " + str( nir_spectrum_uW_per_cm_squared[ 3 ] )
                    + ", " + str( nir_spectrum_uW_per_cm_squared[ 4 ] )
                    + ", " + str( nir_spectrum_uW_per_cm_squared[ 5 ] )

                    )
                print( mirror_data )
                #print()
                gc.collect()

        else:
            #print( "record_pause_state = 1, which means it's paused")
            print( "paused..." )
            last_record_pause_state = 1

        if display_state == 0:                      # if it's in table mode
            if last_display_state != 0:             # if the last state was not table mode
                display.show( display_group_table ) # bring up the table display by loading the table background graphics
            # load data values into the table display
            if display:
                gc.collect()
                if inputs[0] or startup:
                    inputs = check_inputs( pushbutton, touch_screen, pushbutton_last_press_state, screen_record_pause_last_press_state, source_lamps_last_press_state )
                    day_date = "UID:{0} {1:04}-{2:02}-{3:02}".format( UID, timestamp.tm_year, timestamp.tm_mon, timestamp.tm_mday )
                    # don't take time to update display if not changed:
                    #if battery_voltage > LOW_BATTERY_VOLTAGE:
                    if text_group[ GROUP.DAY_DATE ].text != day_date:
                        text_group[ GROUP.DAY_DATE ].text = day_date
                    if text_group[ GROUP.BATCH ].text != batch_label:
                        text_group[ GROUP.BATCH ].text = batch_label
                    time_text = "{0:02}:{1:02}:{2:02}Z".format(timestamp.tm_hour, timestamp.tm_min, timestamp.tm_sec)
                    text_group[ GROUP.TIME ].text = time_text
                    text_group[ GROUP.SURFACE_TEMP ].text = "{:4}C".format( surface_temperature_C )
                    for i,band in enumerate( VIS_BANDS ):
                        waveband_string = "{:5}".format( visible_spectrum_uW_per_cm_squared[ i ] )
                        text_group[ GROUP.VIS_VALUES + i ].text = waveband_string
                    for i,band in enumerate( NIR_BANDS ):
                        waveband_string = "{:4}".format( nir_spectrum_uW_per_cm_squared[ i ] )
                        text_group[ GROUP.NIR_VALUES + i ].text = waveband_string
                    text_group[ GROUP.AIR_TEMPERATURE ].text = "{:}C".format( air_temperature_C )
                    gc.collect()
        last_display_state = 0                      # reset the last state to reflect the new mode

        # wait loop
        exit_wait = 0
        remaining_wait_time = desired_sample_interval_s - (time.monotonic() - last_sample_time_s)
        while remaining_wait_time > 0 and exit_wait == 0:
            inputs = check_inputs( pushbutton, touch_screen, pushbutton_last_press_state, screen_record_pause_last_press_state, source_lamps_last_press_state )
            no_change = inputs[0]
            # begin interpret inputs
            screen_record_pause_press_state = inputs[2] # press[0]
            if screen_record_pause_press_state != screen_record_pause_last_press_state and screen_record_pause_press_state == True:
            # if screen circle press state is different than the last; detect either rising or falling edge AND it's pressed now; detect rising edge
                record_pause_state = (record_pause_state + 1) % number_of_record_pause_states   # advance the state by one modulo the number of states
                exit_wait = 1                                                                   # implement the change now, don't wait for the rest of the interval
            screen_record_pause_last_press_state = screen_record_pause_press_state              # reset the last state to prepare for future edge detection

            pushbutton_press_state = inputs[1]
            if pushbutton_press_state:              # if the button is pushed, whatever the current record pause state is...
                if pushbutton_last_press_state == False: # if the button is just now being pushed and wasn't before; detect rising edge
                    exit_wait = 1                   # don't wait for the interval to finish, go ahead and jump to recording (if it's already in record mode, skip the current interval anyway)
                record_pause_state = 0              # set system to recording
                pushbutton_last_press_state = True  # set the last press state to "pressed"
            elif pushbutton_last_press_state:       # if the button goes from last pushed to now not pushed; detect falling edge
                record_pause_state = 1              # set system to pause
                pushbutton_last_press_state = False # set the last press state to "not pressed"
                exit_wait = 1                       # take action to implement pause right now
                # if the button is not pushed and was last not pushed, don't change state

            show_record_pause_icon( display_group_table, record_pause_state, display_state, 1 )

            source_lamps_press_state = inputs[3] # press[1]
            if source_lamps_press_state != source_lamps_last_press_state and source_lamps_press_state == True:
            # if screen trapezoid press state is different than the last; detect either rising or falling edge AND it's pressed now; detect rising edge
                source_lamps_state = (source_lamps_state + 1) % number_of_source_lamps_states # advance the state by one modulo the number of states
            source_lamps_last_press_state = source_lamps_press_state # reset the last state to prepare for future edge detection

            # implement lamp state
            if source_lamps_state == 0:
                source_lamps_off(VIS_sensor, NIR_sensor, aux_lamp)
            else:
                source_lamps_on(VIS_sensor, NIR_sensor, aux_lamp)


            remaining_wait_time = desired_sample_interval_s - (time.monotonic() - last_sample_time_s)
            #print( "remaining_wait_time == {:}".format(remaining_wait_time))
            time.sleep( 0.1 )
        startup = False
        # return to top of infinite loop

# function definitions below

def check_inputs( pushbutton, touch_screen, pushbutton_last_press_state, screen_record_pause_last_press_state, source_lamps_last_press_state ):
    pushbutton_press_state = pushbutton_pressed( pushbutton )
    press = screen_pressed( touch_screen )
    if pushbutton_last_press_state == pushbutton_press_state and screen_record_pause_last_press_state == press[0] and source_lamps_last_press_state == press[1]:
        no_change = True
    else:
        no_change = False
    return no_change, pushbutton_press_state, press[0], press[1]

def stall():
    print("intentionally stalled, press return to continue")
    input_string = False
    while input_string == False:
        input_string = input().strip()

def report_memory( message ):
    gc.collect()
    print("UID: {:} Memory B: Used {:} /Free {:} {}".format( UID, gc.mem_alloc(), gc.mem_free(), message))
    #last_alloc = gc.mem_alloc()
    #print( "memory used here, bytes: {}".format (gc.mem_alloc() - last_alloc))
    #time.sleep(5)

def initialize_pushbutton():
    pushbutton = digitalio.DigitalInOut( board.D12 )
    pushbutton.direction = digitalio.Direction.INPUT
    pushbutton.pull = digitalio.Pull.UP
    return pushbutton

def pushbutton_pressed( pushbutton ):
    pushbutton_press_state = not pushbutton.value   #active low, so True is notpushed and False is pushed
    return pushbutton_press_state                   #pushbutton_press_state is True if button is being pushed

def initialize_spi():
    try:
        # spi has already been initialized by displayio/adafruit_ili9341, but reinitialize it here anyway
        spi_bus = board.SPI()
        # initialized spi bus
        return spi_bus
    except ValueError as err:
        print( "Error: spi bus fail: {:}".format(err) )
        return False

def initialize_touch_screen( spi_bus ):
    touch_screen_chip_select = digitalio.DigitalInOut(board.D6)
    try:
        touch_screen = Adafruit_STMPE610_SPI(spi_bus, touch_screen_chip_select)
        return touch_screen
    except RuntimeError as err:
        print("Error: touch screen runtime fail: {:}".format(err))
        return False
    except ValueError as err:
        print("Error: touch screen value fail {:}".format(err))
        return False

def screen_pressed( touch_screen ):
    if touch_screen != False:
        record_pause_press_state = 0
        table_graph_press_state = 0
        source_lamp_press_state = 0
        while not touch_screen.buffer_empty:
            touch_data = touch_screen.read_data()
            touch_x = touch_data[ 1 ]
            touch_y = touch_data[ 0 ]
            touch_pressure = touch_data[ 2 ]
            #print( "X: %d, Y: %d, press: %d" % ( touch_x, touch_y, touch_pressure ) )
            if ( touch_x < 2000 ) and ( touch_y > 1500) and ( touch_y < 3600 ) and ( touch_y > 2400 ): #1750, 2950
                #print ("record_pause_press_state = 1")
                record_pause_press_state = True
            if ( touch_x > 1300 ) and ( touch_y < 2000 ) and ( touch_y < 2000 ): #1800, 1100
                source_lamp_press_state = True
                #print("lamp control pushed")
            #if ( touch_x < 1000 ) and ( touch_y > 1000 ) and ( touch_y < 3000 ):
            #    table_graph_press_state = True
        return (record_pause_press_state, source_lamp_press_state)
    else:
        return ( False, False )

def initialize_display( spi_bus ):
    if spi_bus == False:
        return False
    try:
        # displayio/dafruit_ili9341 library owns the pins until display release
        displayio.release_displays()
        tft_display_cs = board.D9
        tft_display_dc = board.D10
        display_bus = displayio.FourWire( spi_bus, command=tft_display_dc, chip_select=tft_display_cs )
        display = adafruit_ili9341.ILI9341( display_bus, width=SCREENSIZE_X, height=SCREENSIZE_Y, rotation=180 )
        # initialized display
        return display
    except ValueError as err:
        print("Error: display fail {:}".format(err))
        return False

def initialize_display_group( display ):
    if display == False:
        print("no display")
        return False
    display_group = displayio.Group()
    return display_group

def create_table_screen( display, display_group ):
    RED = 0xFF0022
    full_spectrum_frame( display_group, RED )
    text_group = full_spectrum_text_groups( display_group )
    return text_group

def full_spectrum_frame( table_group, border_color ):
    # begin full spectrum frame
    if table_group == False:
        return
    palette = displayio.Palette(1)
    palette[0] = border_color
    background_rectangle = vectorio.Rectangle(pixel_shader=palette, width=SCREENSIZE_X, height=SCREENSIZE_Y, x=0, y=0)
    table_group.append( background_rectangle )
    palette = displayio.Palette(1)
    palette[0] = 0xFFFFFF
    border_width = 7 #0 #7
    foreground_rectangle = vectorio.Rectangle(pixel_shader=palette, width=SCREENSIZE_X - 2*border_width, height=SCREENSIZE_Y - 2*border_width, x=border_width, y=border_width)
    table_group.append( foreground_rectangle )
    batch_x = 258
    batch_border_offset = 0
    batch_height_y = 26
    palette = displayio.Palette(1)
    palette[0] = border_color
    batch_border = vectorio.Rectangle(pixel_shader=palette, width=SCREENSIZE_X - batch_x - border_width - batch_border_offset, height=batch_height_y, x=batch_x, y=border_width + batch_border_offset)
    table_group.append( batch_border )

    batch_area_border_width = 3
    palette = displayio.Palette(1)
    palette[0] = 0xFFFFFF
    batch_clear_area = vectorio.Rectangle(pixel_shader=palette, width=SCREENSIZE_X - batch_x - border_width - batch_area_border_width, height=batch_height_y - batch_area_border_width, x=batch_x + batch_area_border_width, y=border_width)
    table_group.append( batch_clear_area )

    #Draw screen switch button:
    if False:
        palette = displayio.Palette(1)
        palette[0] = 0xC1C1C1
        screen_button_triangle = vectorio.Polygon( pixel_shader=palette, points = [(250, 60), (250, 160), (310, 110)], x=0, y=0)
        table_group.append(screen_button_triangle)

    #Draw the record_pause_state circle:
    palette = displayio.Palette(1)
    palette[0] = 0xC1C1C1
    record_pause_circle = vectorio.Circle( pixel_shader=palette, radius=45, x=170, y=70 )
    table_group.append( record_pause_circle )

    #Draw source trapezoid
    source_trapezoid = vectorio.Polygon( pixel_shader=palette, points = [(150, 170), (190, 170), (210, 230), (130, 230)], x=0, y=0)
    table_group.append(source_trapezoid)

def full_spectrum_text_groups( table_group ):
    if table_group == False:
        return False
    # Fixed width font
    fontPixelWidth, fontPixelHeight = terminalio.FONT.get_bounding_box()
    text_color = 0x000000 # black text for readability
    text_group = displayio.Group( scale = 2, x = 15, y = 20 ) #scale sets the text scale in pixels per pixel
    try:
        # Name each text_group with some: GROUP.X = len(text_grup), then use in text_group[ GROUP.X ]
        # Order doesn't matter for that (but is easier to figure out if in "display order")
        # LINE 1
        GROUP.DAY_DATE = len(text_group) # text_group[ i ] day date
        text_area = label.Label( terminalio.FONT, color = text_color ) #text color
        text_area.y = -1
        text_area.x = 0
        text_group.append( text_area )
        GROUP.BATCH = len(text_group) #text_group[ i ] batch_display_string
        text_area = label.Label( terminalio.FONT, color = text_color )
        text_area.y = -2
        text_area.x = 127
        text_group.append( text_area )
        # LINE 2
        GROUP.TIME = len(text_group) #text_group[ i ] time
        text_area = label.Label( terminalio.FONT, color = text_color )
        text_area.y = 12
        text_area.x = 0
        text_group.append( text_area )
        # surface temperature label, doesn't need a name
        text_area = label.Label( terminalio.FONT, text="Surface:", color = text_color )
        text_area.y = 12
        text_area.x = 70
        text_group.append( text_area )
        GROUP.SURFACE_TEMP = len(text_group) #text_group[ i ] surface temperature
        text_area = label.Label( terminalio.FONT, color = text_color )
        text_area.y = text_group[-1].y
        text_area.x = text_group[-1].x + len( text_group[-1].text ) * fontPixelWidth # use the previous text to get offset
        text_group.append( text_area )
        # LINE 3
        # units_string, doesn't need a name
        text_area = label.Label( terminalio.FONT, text="nm: uW/cm^2", color = text_color )
        text_area.y = 24
        text_area.x = 0
        text_group.append( text_area )

        # air temp label, doesn't need a name
        text_area = label.Label( terminalio.FONT, text="Air:", color = text_color )
        text_area.x = 94
        text_area.y = 24
        text_group.append( text_area )
        GROUP.AIR_TEMPERATURE = len(text_group) #text_group[ i ] air temperature
        text_area = label.Label( terminalio.FONT, color = text_color )
        text_area.y = text_group[-1].y
        text_area.x = text_group[-1].x + len(text_group[-1].text) * fontPixelWidth
        text_group.append( text_area )
        # LINE 5..10
        #text groups[ i..+5 ] VIS channels labels
        vis_start_x = 0
        for waveband_index,nm in enumerate(VIS_BANDS):
            vis_start_y = 36 + 12 * waveband_index
            # just labels
            label_string = "{:1}{:03}: ".format( VIS_BAND_PREFIXES[waveband_index], nm )
            text_area = label.Label( terminalio.FONT, text=label_string, color = text_color )
            text_area.x = vis_start_x
            text_area.y = vis_start_y
            text_group.append( text_area )
        GROUP.VIS_VALUES = len(text_group) #text groups[ i..+5 ] VIS channels. Just the first one, we `+ i` it
        # x is always the same: a column
        vis_start_x = vis_start_x + len( label_string ) * fontPixelWidth
        for waveband_index,nm in enumerate(VIS_BANDS):
            vis_start_y = 36 + 12 * waveband_index
            text_area = label.Label( terminalio.FONT, color = text_color )
            text_area.x = vis_start_x
            text_area.y = vis_start_y
            text_group.append( text_area )
        #text groups[ i..+5 ] NIR channels labels
        nir_start_x = 82
        for waveband_index,nm in enumerate(NIR_BANDS):
            nir_start_y = 36 + 12 * waveband_index
            # just labels
            label_string = "{:03}: ".format( nm )
            text_area = label.Label( terminalio.FONT, text=label_string, color = text_color )
            text_area.x = nir_start_x
            text_area.y = nir_start_y
            text_group.append( text_area )
        GROUP.NIR_VALUES = len(text_group) #text groups[ i..+5 ] NIR channels. Just the first one, we `+ i` it
        # x is always the same: a column
        nir_start_x = nir_start_x + len( label_string ) * fontPixelWidth
        for waveband_index,nm in enumerate(NIR_BANDS):
            nir_start_y = 36 + 12 * waveband_index
            text_area = label.Label( terminalio.FONT, color = text_color )
            text_area.x = nir_start_x
            text_area.y = nir_start_y
            text_group.append( text_area )
    except RuntimeError as err:
        if str(err) == "Group full":
            print("### Had this many groups when code failed: {:}".format(len(text_group)))
        raise
    table_group.append( text_group )
    #print("TG max_size {:}".format(len(text_group))) # to figure max_size of group
    return text_group

class GROUP:
    '''
    class GROUP:
      a group to gather the index names

      GROUP.X = 1
      magically creates the class variable X in GROUP,
      so we don't have to explicitly declare it

      = len(text_group)
      how many text_groups already made, == index of this text_group

      something = GROUP.X
      Then you just use it. But, this ensures that you assigned to GROUP.X before you read from it.
    '''
    pass

def create_welcome_screen( display ):
    welcome_group = initialize_display_group( display )
    border_color = 0xFF0022 # red
    front_color = 0x0000FF # blue
    if (display == False) or ( welcome_group == False):
        print("No display")
        return
    border = displayio.Palette(1)
    border[0] = border_color
    front = displayio.Palette(1)
    front[0] = front_color
    outer_rectangle = vectorio.Rectangle(pixel_shader=border, width=320, height=240, x=0, y=0)
    welcome_group.append( outer_rectangle )
    front_rectangle = vectorio.Rectangle(pixel_shader=front, width=280, height=200, x=20, y=20)
    welcome_group.append( front_rectangle )
    text_group = displayio.Group( scale=6, x=50, y=120 )
    text = "STELLA"
    text_area = label.Label( terminalio.FONT, text=text, color=0xFFFFFF )
    text_group.append( text_area ) # Subgroup for text scaling
    welcome_group.append( text_group )
    return welcome_group


def initialize_i2c():
    # Initialize the i2c bus at 100kHz, the fastest speed the tir sensor will accommodate.
    # Default i2c speed is 400kHz. At that speed, the TIR sensor will fail to communicate.
    try:
        i2c_bus = busio.I2C( board.SCL, board.SDA, frequency=100000 )
        # initialized i2c bus
        return i2c_bus
    except ValueError as err:
        print( "Error: i2c bus fail: {:}".format(err) )
        return False

def initialize_uart():
    try:
        uart_bus = busio.UART(board.TX, board.RX, baudrate=115200, bits=8, parity=1, stop=1)
        # initialized uart bus
        return uart_bus
    except ValueError as err: # known error behavior
        print( "Error: uart bus fail: {:}".format(err) )
        return False

def initialize_sample_indicator():
    try:
        sample_indicator_LED = digitalio.DigitalInOut( board.A0 )
        sample_indicator_LED.direction = digitalio.Direction.OUTPUT
        sample_indicator_LED.value = True #active low, True is off
        # initialized sample_indicator
        return sample_indicator_LED
    except Exception as err: # TBD: identify some typcial errors
        print( "Error: led pin init failed {:}".format(err) )
        return False

def initialize_battery_monitor():
    try:
        battery_monitor = AnalogIn(board.VOLTAGE_MONITOR)
        return battery_monitor
    except Exception as err: # TBD: identify some typical errors
        print( "Error: battery monitor initialization fail {:}".format(err) )

def initialize_power_indicator():
    try:
        power_indicator_LED = digitalio.DigitalInOut( board.D13 )
        power_indicator_LED.direction = digitalio.Direction.OUTPUT
        power_indicator_LED.value = True #active high, True is on
        # initialized sample_indicator
        return power_indicator_LED
    except Exception as err: # TBD: identify some typical errors
        print( "Error: power indicator initialization failed {:}".format(err) )
        return False

def set_power_indicator( power_indicator_LED, set_value ):
    try:
        power_indicator_LED.value = set_value
    except Exception as err: # TBD: identify some typical errors
        print( "Error: power indicator set failed {:}".format(err) )

def check_battery_voltage( battery_monitor ):
    if battery_monitor == False:
        return False
    try:
        battery_voltage = round(( battery_monitor.value * 3.3 ) / 65536 * 2, 2 )
        return battery_voltage
    except ValueError as err:
        print( "Error: battery monitor fail: {:}".format(err) )
        return False

def low_battery_voltage_notification( power_indicator, text_group ):
    for index in range( 5 ):
        power_indicator.value = False
        time.sleep( 0.05 )
        power_indicator.value = True
        time.sleep( 0.05 )
    text_group[ GROUP.DAY_DATE ].text = "Low battery: plug in"
    time.sleep(0.6)

def initialize_real_time_clock( i2c_bus ):
    if i2c_bus == False:
        return False
    try:
        rtc = adafruit_pcf8523.PCF8523( i2c_bus )
        # initialized real time hardware_clock
        return rtc
    except ValueError as err: # known error behavior
        print( "Error: real time hardware_clock init fail: {:}".format(err) )
        return False

def initialize_sd_card( spi_bus ):
    if spi_bus == False:
        return False # will fail later if we try use the directory /sd
    # NOTE: pin D10 is in use by the display for SPI D/C, not available for the SD card chip select.
    # Modify the Adalogger Featherwing to use pin D11 for SD card chip select
    sd_cs = board.D11
    try:
        sdcard = sdcardio.SDCard( spi_bus, sd_cs )
        vfs = storage.VfsFat( sdcard )
        storage.mount( vfs, "/sd" )
        # initialized sd card
        return sdcard
    except ValueError as err: # known error behavior
        print( "Error: sd-card init fail: {:}".format(err) )
        return False
    except OSError as err:
        #TBD distinguish between '[Errno 17] File exists' (already mounted), no card and full card conditions.
        # "card not present or card full: [Errno 5] Input/output error" retry?
        print( "Error: sd card fail: card not present or card full: {:} ".format(err) )
        return False
        #TBD catch the "digital pin already in use" error

def update_batch( timestamp ):
    datestamp = "{:04}{:02}{:02}".format( timestamp.tm_year, timestamp.tm_mon, timestamp.tm_mday)
    try:
        with open( "/sd/batch.txt", "r" ) as b:
            try:
                previous_batchfile_string = b.readline()
                previous_datestamp = previous_batchfile_string[ 0:8 ]
                previous_batch_number = int( previous_batchfile_string[ 8: ])
            # TBD: catch error when /sd doesn't exist
            except ValueError:
                previous_batch_number = 0
                # corrupted data in batch number file, setting batch to 0
            if datestamp == previous_datestamp:
                # this is the same day, so increment the batch number
                batch_number = previous_batch_number + 1
            else:
                # this is a different day, so start the batch number at 0
                batch_number = 0
    except OSError:
            print( "batch.txt file not found" )
            batch_number = 0
    batch_string = ( "{:03}".format( batch_number ))
    batch_file_string = datestamp + batch_string
    try:
        with open( "/sd/batch.txt", "w" ) as b:
            b.write( batch_file_string )
        # TBD: catch error when /sd doesn't exist
    except OSError as err:
        print("Error: writing batch.txt {:}".format(err) )
        pass
    batch_string = ( "{:}".format( batch_number ))
    return batch_string

def initialize_TIR_sensor( i2c_bus ):
    if i2c_bus == False:
        return False
    try:
        TIR_sensor = adafruit_mlx90614.MLX90614( i2c_bus )
        # initialized thermal infrared sensor
        return TIR_sensor
    except ValueError as err:
        print( "Error: thermal infrared sensor fail: {:}".format(err) )
        return False

def initialize_AT_sensor( i2c_bus ):
    if i2c_bus == False:
        return False
    try:
        AT_sensor = adafruit_mcp9808.MCP9808( i2c_bus )
        # initialized air temperature sensor
        return AT_sensor
    except ValueError as err:
        print( "Error: air temperature sensor fail: {:}".format(err) )
        return False

def initialize_WX_sensor( i2c_bus ):
    if i2c_bus == False:
        return False
    try:
        WX_sensor = adafruit_bme280.Adafruit_BME280_I2C( i2c_bus )
        # initialized weather sensor
        return WX_sensor
    except RuntimeError as err:
        print( "Runtime Error: weather sensor fail {:}".format(err) )
        return False
    except ValueError as err:
        print( "Error: weather sensor fail {:}".format(err) )
        return False

def initialize_auxilliary_lamp():
    try:
        aux_lamp = digitalio.DigitalInOut( board.D4 )
        aux_lamp.direction = digitalio.Direction.OUTPUT
        aux_lamp.value = True #active low, True is off
        # initialized sample_indicator
        return aux_lamp
    except Exception as err: # TBD: identify some typcial errors
        print( "Error: auxilliary lamp control pin init failed {:}".format(err) )

def initialize_VIS_sensor( i2c_bus ):
    if i2c_bus == False:
        return False
    try:
        VIS_sensor = adafruit_as726x.AS726x_I2C( i2c_bus )
        VIS_sensor.conversion_mode = VIS_sensor.MODE_2
        # initialized visible spectrum sensor
        #TBD why 16 gain and 166 ms integration time? Because those are the values during the sensor calibration.
        VIS_sensor.gain = 16
        VIS_sensor.integration_time = 166
        return VIS_sensor
    except ValueError as err:
        print( "Error: visible spectrum sensor fail: {:}".format(err) )
        return False

def initialize_NIR_sensor( uart_bus ):
    if uart_bus == False:
        return False
    uart_bus.write(b"AT\n")
    data = uart_bus.readline() #typical first response from the device is b'ERROR\n', which indicates it is present.
    if data is None:
        print ( "Error: near infrared spectrum sensor fail" )
        return False
    else:
        # initialized near infrared spectrum sensor
        # check gain setting
        b = b"ATGAIN\n"
        uart_bus.write(b)
        data = uart_bus.readline()
        #print ( "# NIR spectrum default GAIN (2 = 16x gain): {:}".format(data))
        #check integration time setting
        b = b"ATINTTIME\n"
        uart_bus.write(b)
        data = uart_bus.readline()
        # print( "# NIR spectrum default INTTIME (59 * 2.8ms = 165ms): {:}".format(data))
        return uart_bus

def read_clock( rtc ):
    if rtc == False:
        return time.struct_time( (0,1,1,0,0,0,0,1,0) )
    try:
        return rtc.datetime
    except ValueError as err:
        print( "Error: real time clock fail {:}".format(err) )
        return time.struct_time( (0,1,1,0,0,0,0,1,0) )

def timestamp_to_decimal_hour( timestamp ):
    try:
        decimal_hour = timestamp.tm_hour + timestamp.tm_min/60.0 + timestamp.tm_sec/3600.0
        return decimal_hour
    except ValueError as err:
        print( "Error: invalid timestamp: {:}".format(err) )
        return "Error"

def timestamp_to_weekday( timestamp ):
    try:
        clock_days = ( "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" )
        weekday = clock_days[ timestamp.tm_wday ]
        return weekday
    except ValueError as err:
        print( "Error: real time hardware_clock fail: {:}".format(err) )
        return "Error"

def read_TIR_sensor( TIR_sensor ):
    if TIR_sensor == False:
        return -273
    decimal_places = 1
    #TBD significant figures
    try:
        surface_temperature_C = TIR_sensor.object_temperature
        surface_temperature_C = round( surface_temperature_C, decimal_places )
        return surface_temperature_C
    except ValueError as err:
        print( "Error: thermal infrared sensor fail: {:}".format(err) )
        return -273

def read_AT_sensor( AT_sensor ):
    if AT_sensor == False:
        return -273
    # mcp9808 datasheet: accuracy +/- 0.25 C
    decimal_places = 1
    #TBD significant figures
    try:
        air_temperature_C = AT_sensor.temperature
        air_temperature_C = round( air_temperature_C, decimal_places )
        return air_temperature_C
    except ValueError as err:
        print( "Error: air temperature sensor fail: {:}".format(err) )
        return -273

def read_WX_humidity( WX_sensor ):
    if WX_sensor == False:
        return 0
    # bme280 datasheet: accuracy +/- 3 %
    decimal_places = 0
    try:
        humidity_relative_percent = WX_sensor.humidity
        humidity_relative_percent = round( humidity_relative_percent, decimal_places )
        humidity_relative_percent = int( humidity_relative_percent )
        return humidity_relative_percent
    except ValueError as err:
        print( "Error: humidity sensor fail {:}".format(err) )
        return 0

def read_WX_pressure( WX_sensor ):
    if WX_sensor == False:
        return 0
    # bme280 datasheet: accuracy +/- 1 hPa
    decimal_places = 0
    try:
        pressure_hPa = WX_sensor.pressure
        pressure_hPa = round( pressure_hPa, decimal_places )
        pressure_hPa = int( pressure_hPa )
        return pressure_hPa
    except ValueError as err:
        print( "Error: pressure sensor fail {:}".format(err) )
        return 0

def read_WX_altitude( WX_sensor ):
    if WX_sensor == False:
        return -999 # lowest point on land is ~ -413m
    # bme280 datasheet does not indicate altitude accuracy. Useful for differential use only.
    # TBD, allow user to enter starting altitude
    decimal_places = 1
    try:
        altitude_m = WX_sensor.altitude
        altitude_m = round( altitude_m, decimal_places )
        return altitude_m
    except ValueError as err:
        print( "Error: altitude sensor fail {:}".format(err) )
        return -500

def read_VIS_spectrum( VIS_sensor ):
    if VIS_sensor == False:
        return [0 for x in VIS_BANDS]
    decimal_places = 1
    # returns readings in a tuple, six channels of data
    # Calibration information from AS7262 datasheet:
    # Each channel is tested with GAIN = 16x, Integration Time (INT_T) = 166ms and VDD = VDD1 = VDD2 = 3.3V, TAMB=25°C.
    # The accuracy of the channel counts/μW/cm2 is ±12%.
    # Sensor Mode 2 is a continuous conversion of light into data on all 6 channels
    # 450nm, 500nm, 550nm, 570nm, 600nm and 650nm
    # sensor.violet returns the calibrated floating point value in the violet channel.
    # sensor.raw_violet returns the uncalibrated decimal count value in the violet channel.
    # that syntax is the same for each of the 6 channels
    try:
        while not VIS_sensor.data_ready:
            time.sleep(0.01)
        initial_read_time_s = time.monotonic()
        violet_calibrated = round( VIS_sensor.violet, decimal_places )
        blue_calibrated = round( VIS_sensor.blue, decimal_places )
        green_calibrated = round( VIS_sensor.green, decimal_places )
        yellow_calibrated = round( VIS_sensor.yellow, decimal_places )
        orange_calibrated = round( VIS_sensor.orange, decimal_places )
        red_calibrated = round( VIS_sensor.red, decimal_places )
        final_read_time_s = time.monotonic()
        vis_read_time_s = final_read_time_s - initial_read_time_s
        visible_tuple = ( violet_calibrated, blue_calibrated, green_calibrated, yellow_calibrated, orange_calibrated, red_calibrated )
        return visible_tuple
    except ValueError as err:
        print( "Error: visible spectrum sensor fail: {:}".format(err) )
        return [0 for x in VIS_BANDS]

def read_NIR_sensor( NIR_sensor ):
    # returns: R_610nm, S_680nm, T_730nm, U_760nm, V_810nm, W_860nm
    if NIR_sensor == False:
        return [0 for x in NIR_BANDS]
    decimal_places = 1
    b = b"ATCDATA\n"
    NIR_sensor.write(b)
    data = NIR_sensor.readline()
    if data is None:                    #if returned data is of type NoneType
        print( "Alert: Sensor miswired at main instrument board, or not properly modified for uart usage" )
        print( "Enable serial communication via uart by removing solder from the jumpers labeled JP1," )
        print( "and add solder to the jumper labeled JP2 (on the back of the board)" )
        return [0 for x in NIR_BANDS]
    else:
        datastr = "".join([chr(b) for b in data]) # convert bytearray to string
        datastr = datastr.rstrip(" OK\n")
        if datastr == "ERROR":
            print("Error: NIR read")
            return [0 for x in NIR_BANDS]
        # convert to float, and round, in place
        datalist_nir = datastr.split( ", " )
        for n, value in enumerate(datalist_nir): # should be same number as items in NIR_BANDS
            try:
                as_float = float( value )
                value = round( as_float, decimal_places )
            except ValueError:
                print("Failed to convert '{:}' to a float") # only during verbose/development
                value = 0
            # After converting to float, element {:} of the list is: {:0.1f} and is type".format( n, datalist_nir[n] ) )
            datalist_nir[n] = value
    # R_610nm, S_680nm, T_730nm, U_760nm, V_810nm, W_860nm
    return datalist_nir

def write_line_to_file( values, sample_indicator ):
    # we let operations fail if the sdcard didn't initialize
    need_header = False
    try:
        os.stat( DATA_FILE ) # fail if data.txt file is not already there
        #raise OSError # uncomment to force header everytime
    except OSError:
        # setup the header for first time
        need_header = True
    try:
        # with sys.stdout as f: # uncomment to write to console (comment "with open")
        with open( DATA_FILE, "a" ) as f: # open seems about 0.016 secs.
            if sample_indicator != False:
                sample_indicator.value = ON
            if need_header:
                header = (
                    "UID, batch_label, weekday, timestamp, hour_format, decimal_hour, "+
                    "surface_temperature_name, surface_temperature_units, surface_temperature, surface_temperature_calibration, "+
                    "air_temperature_name, air_temperature_units, air_temperature, air_temperature_calibration, "+
                    "relative_humidity_name, relative_humidity_units, relative_humidity, relative_humidity_calibration, "+
                    "air_pressure_name, air_pressure_units, air_pressure, air_pressure_calibration, "+
                    "altitude_uncalibrated_name, altitude_uncalibrated_units, altitude_uncalibrated, altitude_uncalibrated_calibration, "+
                    "visible_spectrum_name, visible_spectrum_wavelength, visible_spectrum_units, visible_spectrum_calibration, "+
                    "V450_wavelength, V450_irradiance, B500_wavelength, B500_irradiance, G550_wavelength, G550_irradiance, Y570_wavelength, Y570_irradiance, O600_wavelength, O600_irradiance, R650_wavelength, R650_irradiance, "+
                    "near_infrared_spectrum_name, near_infrared_spectrum_wavelength, near_infrared_spectrum_units, near_infrared_spectrum_calibration, "+
                    "nir610_wavelength, nir610_irradiance, nir680_wavelength, nir680_irradiance, nir730_wavelength, nir730_irradiance, nir760_wavelength, nir760_irradiance, nir810_wavelength, nir810_irradiance, nir860_wavelength, nir860_irradiance, " +
                    "battery_voltage_name, battery_voltage_units, battery_voltage"+
                    "\n"
                    )
                f.write( header )
            for i,v in enumerate(values): # 0.16 secs to write
                f.write( str(v) )
                #print( str(v) )
                if i < len(values)-1: # no trailing ','
                    f.write(",")
            f.write("\n")
        if sample_indicator != False:
            sample_indicator.value = OFF
        return True
    except OSError as err:
        # TBD: maybe show something on the display like sd_full? this will "Error" every sample pass
        # "[Errno 30] Read-only filesystem" probably means no sd_card
        print( "Error: sd card fail: {:} ".format(err) )
        if sample_indicator != False:
            sample_indicator.value = ON # constant ON to show error, likely no SD card present, or SD card full.
        return False



def show_record_pause_icon( display_group, record_pause_state, mode, create_update ):
    if display_group == False:
        print("No display")
        return
    #print ( len ( display_group ))
    #WHITE = 0xFFFFFF
    BACKGROUND = 0xC1C1C1
    RED = 0xFF0022
    BLACK = 0x000000
    if mode == 0:
        x_center = 170
        y_center = 70
    else:
        x_center = 170
        y_center = 14
    radius = 8
    x_corner = x_center - radius
    y_corner = y_center - radius
    off_screen_y = 300
    width = 18
    split_width = int( width/3 )
    height = 18
    if create_update == 0:
        blank_icon = Rect( x_corner, y_corner, width, height, fill = BACKGROUND )#WHITE)
        recording_icon = Circle( x_center, y_center, radius, fill = RED)
        pause_square_icon = Rect( x_corner, y_corner, width, height, fill = BLACK)
        pause_split_icon = Rect( x_corner + split_width, y_corner, split_width, height, fill = BACKGROUND) #WHITE)
        display_group.append( blank_icon )
        display_group.append( recording_icon )
        display_group.append( pause_square_icon )
        display_group.append( pause_split_icon )
    else:
        len_group = ( len ( display_group ))
        if record_pause_state== 0:
            display_group[ len_group - 4 ].y = y_corner
            display_group[ len_group - 3 ].y = y_corner
            display_group[ len_group - 2 ].y = off_screen_y
            display_group[ len_group - 1 ].y = off_screen_y
        elif record_pause_state== 1:
            display_group[ len_group - 4 ].y = off_screen_y
            display_group[ len_group - 3 ].y = off_screen_y
            display_group[ len_group - 2 ].y = y_corner
            display_group[ len_group - 1 ].y = y_corner

def remove_record_pause_icon ( display_group ):
    len_group = ( len ( display_group ))
    display_group[ len_group - 2 ].y = 250
    display_group[ len_group - 1 ].y = 250

def source_lamps_on(VIS_sensor, NIR_sensor, aux_lamp):
    if aux_lamp != None:
        aux_lamp.value = True # the lamp circuit is active high

    if VIS_sensor != None:
        VIS_sensor.driver_led_current = 12.5 # mA, options 12.5, 25, 50, 100 mA
        VIS_sensor.driver_led = True

    if NIR_sensor != None:
        s = "ATLEDC=48\n"   #0, 16, 32, 48 set the driver to 12.5, 25, 50, 100 mA
        b = bytearray()
        b.extend(s)
        NIR_sensor.write(b)
        #print( "Bytearray sent: %s" % b )
        data = NIR_sensor.readline()
        #print( "Data received: %s" % data)
        s = "ATLED1=100\n" #100 is on, 0 is off
        b = bytearray()
        b.extend(s)
        NIR_sensor.write(b)
        #print( "Bytearray sent: %s" % b )
        data = NIR_sensor.readline()
        #print( "Data received: %s" % data)

def source_lamps_off(VIS_sensor, NIR_sensor, aux_lamp):
    if aux_lamp != None:
        aux_lamp.value = False # the lamp circuit is active high
    if VIS_sensor != None:
        VIS_sensor.driver_led = False
    if NIR_sensor != None:
        s = "ATLED1=0\n" #100 is on, 0 is off
        b = bytearray()
        b.extend(s)
        NIR_sensor.write(b)
        #print( "Bytearray sent: %s" % b )
        data = NIR_sensor.readline()
        #print( "Data received: %s" % data)

main()

