GUI for Creating JSON Recipes

I wanted to share with you all how we are using the access to the JSON recipes via the web application. I have been looking for a way to streamline the creation of student recipes without having to have them all read and write JSON files. While some of my students benefit from digging in behind the scenes, others are just looking to setup their research as easily as possible.

I started by using my Adafruit IO Plus account ($10/mo) to create an online dashboard of widgets that could be used to set the variables associated with a recipe.


I then worked with students to write a Python script that would open a sample JSON file that was dowloaded from the MARSfarm web app. It gets converted it into a Python dictionary that can be modified by reading the feed values from Adafruit IO. The script then overwrites or modifies the dictionary in the appropriate value fields associated with a variable in the recipe. Finally, it exports a JSON file that can be uploaded to the web app.

I found that by modifying an existing JSON file prevented an inadvertent departure from the formatting that the web app is expecting. I am by no means a Pythonista, but the script allows you to:

  • Name your recipe

  • Auto populate the description with the values you have selected from Adafruit IO

  • Select the watering volume from 0-2000mL. It also ensures these are broken up into the requisite number of watering sequences so as not to try and pump more than 80mL in any one minute span

  • Select the time of day you would like your plants to be watered. This is on the hour from 0-23.

  • Select the time of day you would like your lights to turn on. The first light_intensity setting must be for 12:01am, so this is set to [0,0,0,0] and a middle time event is inserted to select your optimal sunrise.

  • Select the photoperiod you would like the lights on for. This setting is also used to establish your daytime and nighttime temperatures.

  • Choose a Red and Blue value for a specific end of the light spectrum you would want to work with.

  • Choose the White light intensity

  • Choose a daytime and nighttime temperature. Like with light intensity, a third setting needs to be created to start off at 12:01.

  • Choose to have the ventilation fan on or off.

Like I said above, it is not the cleanest code, but it works. Feel free to polish/modify as needed.

from Adafruit_IO import Client, Feed, RequestError
import math
import json

#Pull in MARSfarm JSON Template.  Use File Path to Downloaded JSON File
with open("/Users/cregini/example.json") as file:
    data = json.load(file)

#Enter Recipe Name
name = 'Your Recipe Name Here'
data['recipe_name'] = name

# Set to your Adafruit IO key.
# Remember, your key is a secret,
# so make sure not to publish it when you publish this code!
ADAFRUIT_IO_KEY = 'Your Key Here'

# Set to your Adafruit IO username.
# (go to https://accounts.adafruit.com to find your username)
ADAFRUIT_IO_USERNAME = 'Your Username Here'

# Create an instance of the REST client.
aio = Client(ADAFRUIT_IO_USERNAME, ADAFRUIT_IO_KEY)

#Function to convert hex to RGB from Adafruit IO
def hex_to_rgb(hex):
  rgb = []
  for i in (0, 2, 4):
    decimal = int(hex[i:i+2], 16)
    rgb.append(decimal)
  
  return tuple(rgb)

#Create Adafruit IO Feeds.  Your Feed names will be up to you.
volume = aio.feeds('mv1-volume')
watering_time = aio.feeds('mv1-watering-time')
airflow = aio.feeds('mv1-airflow')
sunrise = aio.feeds('mv1-sunrise')
photoperiod = aio.feeds('mv1-photoperiod')
light_spectrum = aio.feeds('mv1-light-spectrum')
light_intensity = aio.feeds('mv1-light-intensity')
day_temp = aio.feeds('mv1-day-temp')
night_temp = aio.feeds('mv1-night-temp')

#Read Adafruit IO Feeds
volume_data = aio.receive(volume.key)
watering_time_data = aio.receive(watering_time.key)
airflow_data = aio.receive(airflow.key)
sunrise_data = aio.receive(sunrise.key)
photoperiod_data = aio.receive(photoperiod.key)
light_spectrum_data = aio.receive(light_spectrum.key)
light_intensity_data = aio.receive(light_intensity.key)
day_temp_data = aio.receive(day_temp.key)
night_temp_data = aio.receive(night_temp.key)

#Determine necessary number of waterings given 80mL max/min
v = int(volume_data.value)

waterings = math.ceil(v/80)

remainder = int(v % 80)

#Assign watering setting and start times based on selected watering volume
waterings_list=[]
if remainder == 0:
    for i in range (waterings):
        waterings_list.append({'start_time': [9,i], 'setting':80})
else:
    for i in range (waterings-1):
        waterings_list.append({'start_time': [9,i], 'setting':80})
    waterings_list.append({'start_time': [9,(waterings-1)], 'setting': remainder})

data['phases'][0]['step'][2]['pump_amount'] = waterings_list    

wt = int(watering_time_data.value)

for i in range(waterings):
    data['phases'][0]['step'][2]['pump_amount'][i]['start_time'][0]=wt

#Check for airflow setting and apply
air=int(airflow_data.value)

data['phases'][0]['step'][0]['circulation_fan'][0]['setting'] = air

if air == 0:
    fan_state = 'OFF'
else:
    fan_state = 'ON'
    
#Initiate 3 light settings to keep lights from turning on at midnight
light_list=[]
for i in range (3):
    light_list.append({'start_time': [0,1], 'setting':[0,0,0,0]})

data['phases'][0]['step'][3]['light_intensity'] = light_list    

#Establish sunrise and photoperiod
on_time = int(sunrise_data.value)
data['phases'][0]['step'][3]['light_intensity'][1]['start_time'][0] = on_time

daylight_hours = int(photoperiod_data.value)
off_time = daylight_hours + on_time
data['phases'][0]['step'][3]['light_intensity'][2]['start_time'][0] = off_time

#Establish light spectrum
color = light_spectrum_data.value
color = color.replace('#', '') #strips leading # off of hex value
color = hex_to_rgb(color) #Uses function on line 26 to grab red and blue values
red = color[0]
blue = color[2]
white = int(light_intensity_data.value)

data['phases'][0]['step'][3]['light_intensity'][1]['setting'][1] = red
data['phases'][0]['step'][3]['light_intensity'][1]['setting'][2] = blue
data['phases'][0]['step'][3]['light_intensity'][1]['setting'][3] = white

#Calculates light intensity.  This will be replaced with an equation provided by MARSfarm.
mol = 12.96

#Estabish 3 temperature settings to maintain nightime temperature
temp_list=[]
for i in range (3):
   temp_list.append({'start_time': [0,1], 'setting':[60]})

data['phases'][0]['step'][1]['temperature'] = temp_list    

#Check and assign temperature settings to coincide with light on/off settings
dt = int(day_temp_data.value)
nt = int(night_temp_data.value)

data['phases'][0]['step'][1]['temperature'][1]['start_time'][0] = on_time
data['phases'][0]['step'][1]['temperature'][2]['start_time'][0] = off_time

data['phases'][0]['step'][1]['temperature'][0]['setting'] = nt
data['phases'][0]['step'][1]['temperature'][1]['setting'] = dt
data['phases'][0]['step'][1]['temperature'][2]['setting'] = nt

#Declare Recipe Description
description = f'Airflow - {fan_state}, {daylight_hours} Hour Days, Day Temp - {dt}F, Night Temp - {nt}F, Irrigation - {v}ml/day, Light - {mol}/mol/day - {red} red, {blue} blue, {white} umol/sec'
data['recipe_variable'] = description

#Write recipe to a JSON file to be uploaded on web app
with open(f"/Users/cregini/{name}.json", 'w') as file:
    json.dump(data, file)

print('Recipe JSON File Complete')
1 Like

Wow - this is really impressive @cregini! The Adafruit.io interfaces are so pretty - this looks great!

@hmw Would you mind sharing the scripts you’ve made for ‘reading’ the JSON recipes here too?

We have also built a stand-alone Flask (python framework) project called ‘Recipe Remixer’ - which is not nearly as pretty. Recipe Remixer was designed to let you edit the JSON file using drop-downs and simple forms. Here’s a video demo:

@Drew would be able to provide a more specific timeline but I expect sometime in April we’ll have ‘Recipe Remixer’ integrated to the front-end.


What I have is not much, just a simple parser that I used to understand and test the file format:
"
#from Environment2019 import data
from json import dumps, load

FILE_NM = ‘jsonfiles/MMV1_TestRecipe_03.02.22.json’

def test():
print(‘Start’)
file_name = FILE_NM
f = open(file_name, encoding=‘utf8’)
try:
data = load(f)
except Exception as e:
print(e)
print(f.tell())
#print(data)
#print(f’Phases: {data.keysprint(data[‘_id’])
print(f"Phase count: {len(data[‘phases’])}")
x = 1
for p in data[‘phases’]:
#print(p)
parse_phase(x, p)
x += 1

def parse_phase(nbr, phase):
print(f’Phase # {nbr}')
print(phase.keys())
print(f"Phase Start: {phase[‘phase_start’]}“)
print(f"Step Count: {len(phase[‘step’])}”)
x = 1
for s in phase[‘step’]:
parse_step(x, s)
x += 1

def parse_step(nbr, step):
print(f’Step # {nbr}‘)
key = step.keys()
#print(f’Step Keys: {key}’)
#print(phase[‘phase_start’])
#print(f"Step: {len(phase[‘step’])}")
#for s in phase[‘step’]:
z = 1
for x in step:
#print(x)
parse_key(x, step)
z += 1

def parse_key(key, env):
print(f’Env: {key}‘)
#print(f’Settings: {len(env)}’)
for x in env:
parse_setting(x)

def parse_setting(x):
print(x)
if name == ‘main’:
test()
"

Thanks for sharing @hmw

One thing I noticed about this that I really like @cregini is your usage of the term ‘Sunrise’ - I think that’s much better than ‘day start’

Brilliant hack. That was an excellent choice to limit the scope of the project to only edit existing fields in a recipe. There are a few things that may end up not working (if I’m being honest) but that’s where I think we haven’t communicated all of the requirements (and frankly limitations) of the hardware clearly enough. I’d say 95% of the work we did on a GUI for building recipes was around adding/rearranging steps within a sequence - especially when they get complex trying to mimic a day cycle.


Good to know - for what it’s worth to anyone reading this, I’ve seen Chris build several cool apps using Adafruit IO - all of them looked better than the actual MARSfarm interface, lol. Adafruit is a super reputable/awesome company that manufactures open-source circuit boards in NYC. Their hardware and software (it would seem) are both great for creating quick prototypes that bring an idea into the real world. Strong recommendation to any maker/electrically inclined teachers to check out Adafruit!


Lastly, I pulled this chunk of code out of the Recipe Remixer we’re working on. You can use this as a test for the recipes you create - as well as instructions for if you did want to add to the functionality of the GUI what constraints you would need to consider.

datavalidation.py

# Author: Ryan Wu
# Created: July 2022
#
# (c) Copyright by MARSfarm

class DataValidator():
    def __init__(self):
        self.error = None
        self.env_setting_checks = {0: self.check_fan, 1: self.check_temp, 2: self.check_pump, 3: self.check_light}

    # ------------------------ Switch Statement Functions ------------------------ #
    # take in list
    def check_fan(self, setting_value):
        try:
            typecasted_setting = int(setting_value[0])
            if int(typecasted_setting) != 0:
                if int(typecasted_setting)!= 1:
                    self.error = "Circulation fan setting must either be 0 (for off) or 1 (for on). You entered {}.".format(setting_value)
                    return True
        except ValueError:
            self.error = "Setting must be either 0 (for off) or 1 (for on). You entered {}.".format(setting_value)
            return True
        return False
    
    def check_temp(self, setting_value):
        try:
            typecasted_setting = int(setting_value[0])
            if int(typecasted_setting) < 65 or int(typecasted_setting > 90):
                self.error = "Temperature must either be between 65 or 90. You entered {}.".format(setting_value)
                return True
        except ValueError:
            self.error = "Temperature must either be between 65 or 90. You entered {}.".format(setting_value)
            return True
        return False

    def check_pump(self, setting_value):
        try:
            typecasted_setting = int(setting_value[0])
            if int(typecasted_setting) < 0 or int(typecasted_setting > 2000):
                self.error = "Pump volume (in ml) must either be above 0 ml or below 2000ml (2 liters). You entered {}.".format(setting_value)
                return True
        except ValueError:
            self.error = "Pump volume (in ml) must either be number 0 ml or below 2000ml (2 liters). You entered {}.".format(setting_value)
            return True
        return False
        
    def check_light(self, setting_value_list):
        #TODO: check on second pass
        for light_value in setting_value_list:
            try:
                typecasted_setting = int(light_value)
                if (typecasted_setting < 0) or (typecasted_setting > 255):
                    self.error = "Setting values must be number between 0 and 255. You entered {}.".format(light_value)
                    return True
            except ValueError:
                self.error = "Setting values must be number between 0 and 255. You entered {}.".format(light_value)
                return True
        return False


    # """
    # function that validates the time block, hour and minute
    
    # Parameters:
    # -----------
    # hour : var to be checked
    # minute : var to be checked

    # Return:
    # True if invalid data, False if data is valid
    # """
    def validate_time(self, hour, minute):
        try:
            typecasted_hour = int(hour)
            if typecasted_hour > 23 or typecasted_hour < 0:
                self.error = "Hour must be a number between 0 and 23. You entered: {}".format(typecasted_hour)
                return True
        except ValueError:
            self.error = "Hour must be a number between 0 and 23. You entered: {}".format(hour)
            return True
        
        # checking minute for number between 0 and 60
        try:
            typecasted_min = int(minute)
            if typecasted_min > 60 or typecasted_min < 0:
                self.error = "Minute must be a number between 0 and 60. You entered: {}".format(typecasted_min)
                return True
        except ValueError:
            self.error = "Minute must be a number between 0 and 23. You entered: {}".format(minute)
            return True
        return False
    
    def validate_setting(self, setting_input, env_index):
        # TODO: parse setting_input
        # 1. check for blank, not int, send typecasted here \/
        return self.env_setting_checks.get(env_index)(setting_input)
    
    def validate_all(self, hour, minute, setting_input, env_index):
        error_in_time = self.validate_time(hour, minute)
        error_in_setting = self.validate_setting(setting_input, env_index)
        if error_in_time == True or error_in_setting == True:
            return True
        return False

    def validate_new_file_name(self, new_name, file_list):
        if len(new_name) == 0:
            self.error = "You must enter a new file name. The file name was left blank."
            return True
        chars_not_allowed = set('!@#$%^&*()+=[];:."')
        if any((char in chars_not_allowed) for char in new_name):
            self.error = "Special Chars !@#$%^&*()+=[];:. not allowed."
            return True

        ascii_approved_name = ""
        ascii_approved_name += new_name
        ascii_approved_name += ".json"
        for name in file_list:
            if str(name) == str(ascii_approved_name):
                self.error = "You've already named a file {}. Choose a different name!".format(new_name)
                return True
        return False

@cregini here is some more insight regarding how the lighting on the MV1 works

GPT Insights

This has proven to be sufficient context for most use cases - especially when paired with a couple of other ‘similar’ recipes as examples. For example, if you knew you wanted a DLI of 30 and a ‘controlled’ temp of 82 you could download both of those recipes and add them to this prompt to have it create a new recipe for you.