Where does each piece run before you write any code?
The operator drops a file on a Perspective File Upload component and expects it in a Dropbox folder. The file makes four hops to get there, and most failures happen at a hop the operator never sees. Ignition scripting runs on Jython, which cannot load the CPython Dropbox SDK and its dependencies. For that reason the working build puts the SDK in a small Flask service. Getting the Perspective upload event to pass raw file bytes straight to Flask did not work either. The build that does work writes the upload to a temporary folder and sends Flask only the file's path. Flask then opens that file from disk and uploads it.
| Hop | Runs on | What it touches |
|---|---|---|
| File Upload component | Operator's browser | Streams the file to the Gateway |
onFileReceived script |
Ignition Gateway (gateway scope) |
event.file.copyTo() into C:/temp/dropbox_upload/
|
Project library dropbox_rest
|
Ignition Gateway |
system.net.httpClient() POST to http://127.0.0.1:9876/upload
|
Flask app.py
|
Same host as the Gateway (loopback bind) | Reads the upload folder, writes C:/temp/dropbox_download, reads dropbox_token.json
|
| Dropbox API | Cloud | Receives files_upload / serves files_download
|
Handing off a path only works when Flask and the Gateway see the same filesystem. Co-locating them is the configuration that works as written. The downside is that at scale a Python service on the Gateway host competes with Ignition for CPU and memory. If you move Flask to another host, you have to replace the path handoff with a shared folder that both service accounts can reach, or send the file bytes in the request body. You also have to rebind Flask off loopback and put TLS on the link.
Check before moving on:
- Create
C:/temp/dropbox_uploadandC:/temp/dropbox_downloadon the Gateway host. - Find the account the Ignition Gateway service runs as (Windows Services console). Grant it write access to the upload folder.
- Grant the account that will run Flask read access to the upload folder and write access to the download folder.
How do you get a Dropbox token that outlives the first session?
Dropbox issues short-lived access tokens. A service that stores only the access token works during commissioning and then returns authentication errors later. Request offline access so the OAuth flow also returns a refresh token. The service trades that refresh token for new access tokens with no operator involved.
- In the Dropbox developer console, create an app with Scoped access and Full Dropbox access.
- On the Permissions tab, enable
files.metadata.read,files.metadata.write,files.content.read,files.content.write. Submit the permissions before you authorize. A token carries only the scopes that were granted when it was issued, so any scope you add later requires re-running the auth flow. - Record the App Key and App Secret. Keep them out of the Ignition project and out of source control.
- On the Gateway host, install the packages with
pip install dropbox flaskunder Python 3.x. - Run the one-time auth script. It builds
DropboxOAuth2FlowNoRedirect(APP_KEY, APP_SECRET, token_access_type='offline')and prints an authorize URL. Open the URL, click Allow, and paste the code back into the script. The script then writesaccess_tokenandrefresh_tokentodropbox_token.json.
Check: open dropbox_token.json and confirm that refresh_token is present and non-empty. If it is missing, the flow ran without token_access_type='offline'.
How should the Flask bridge hold and refresh the token?
The starting version of app.py loads the stored access token and calls users_get_current_account() before every request. On AuthError it refreshes by building a client from the refresh token and reading the private attribute _oauth2_access_token. That works, but it has four weak spots:
- Every upload costs an extra Dropbox round trip just to probe the token.
- Reading
_oauth2_access_tokendepends on SDK internals that can change between releases. -
TOKEN_FILE = "dropbox_token.json"is a relative path. If Flask starts from another working directory, for example as a scheduled task or a service, it finds no file, setsdbx = None, and returns 401 until someone restarts it. -
file_pathcomes from the request body unchecked. Any caller that reaches the port can make Flask upload any file the Flask account can read.
A Dropbox client built with oauth2_refresh_token, app_key and app_secret refreshes its own access token when it expires. Build it once and drop the probe:
The /upload exception handler also returns 200 with success: false. Add , 500 to that return so the HTTP status code agrees with the body.
| Setting | Location | Effect |
|---|---|---|
host='127.0.0.1' |
app.run() |
Only processes on the Gateway host can reach the API. Keep it this way while co-located. |
port=9876 |
app.run() and both URLs in dropbox_rest
|
Must match on both sides and must not already be in use |
debug=True |
app.run() |
Turns on the interactive debugger and auto-reloader. Set it to False outside the bench. |
WriteMode('overwrite') |
files_upload() |
A file with the same name replaces the existing Dropbox file without warning |
f.read() |
files_upload() |
Loads the whole file into memory in one request. Large files need the SDK's upload-session calls. |
Check: start python app.py, place test.txt in the upload folder, and put this body in body.json:
{"path": "/TestFolder/test.txt", "file_path": "C:/temp/dropbox_upload/test.txt"}
Run curl -X POST http://127.0.0.1:9876/upload -H "Content-Type: application/json" --data @body.json. Expect "success": true and "file_path": "/TestFolder/test.txt". Until this passes, Ignition is not part of the problem.
Why system.net.httpClient and not system.net.httpPost?
Trying to post this request with system.net.httpPost cost a full day of troubleshooting. Moving to system.net.httpClient() fixed it. The client's post() serializes a dict passed as data into a JSON body. It also returns a response object for every status code, including 4xx and 5xx. That means the Flask error JSON reaches your script and you can read it through .json, .statusCode and .text. Create one client at library level and reuse it.
Put the following in a Project Library script named dropbox_rest. Two changes from the starting version: the temp file name carries a UUID prefix, so two operators uploading the same file name at the same moment do not overwrite each other's staged copy, and the staged file is deleted afterward. Without the delete, C:/temp/dropbox_upload keeps growing.
Dropbox paths must begin with /. Pass /TestFolder, not TestFolder.
Check: do not use the Designer Script Console for this test unless the Designer is running on the Gateway host. The console runs in designer scope, so 127.0.0.1 points at the engineering laptop, and there is no event.file to pass. Test from the component in a live session (next section) and read the result in the Gateway logs under Status > Diagnostics > Logs, filtered on Dropbox.
How do you wire the File Upload component so the operator sees the result?
If the result only goes to the log, the screen looks the same whether the upload succeeded or failed. Write the result back to the view. The label name below is an example; use whatever label you have on the view.
def runAction(self, event):
result = dropbox_rest.uploadFileToDropbox(event, '/TestFolder')
lbl = self.getSibling('lblUploadStatus')
if result.get('success'):
lbl.props.text = 'Saved to ' + result.get('file_path')
else:
lbl.props.text = 'Upload failed: ' + str(result.get('error'))
Put this in the component's onFileReceived event. The event runs on the Gateway no matter where the browser is, so a Perspective session on another PC or a panel stages the file on the Gateway host. The same 127.0.0.1 call still reaches Flask. Also check the component's maximum file size property. It rejects oversize files in the browser before your script ever runs.
Check: upload a small text file from a session. Expect Saved to /TestFolder/<name> on the label, the file in Dropbox, and an empty C:/temp/dropbox_upload.
Why does the Download button do nothing on screen?
The download path works; the button never uses what it gets back. The starting button script calls dropbox_rest.downloadFileFromDropbox() and ignores the return value, so the file reaches Flask, then the Gateway, and stops there. A second problem: system.net.httpGet returns text, which corrupts binary files such as PDFs, images and recipe archives. Fetch the response as bytes and push them to the browser:
# dropbox_rest
import java.net.URLEncoder
def downloadFileFromDropbox(dropbox_path):
url = API + '/download?dropbox_path=' + java.net.URLEncoder.encode(dropbox_path, 'UTF-8')
resp = client.get(url)
if resp.statusCode != 200:
logger.error('Download failed: %s' % resp.text)
return None
return resp.body
# Download button, onActionPerformed
def runAction(self, event):
dropbox_path = '/TestFolder/TestFile_1.txt'
data = dropbox_rest.downloadFileFromDropbox(dropbox_path)
if data:
system.perspective.download(dropbox_path.split('/')[-1], data)
Flask also leaves every downloaded file in C:/temp/dropbox_download. Schedule a cleanup for that folder or delete each file after send_file returns.
Check: click the button in a session. The browser should save the file, and a binary file should open without errors.
What is the screen telling you when a transfer fails?
| Operator sees / log shows | Hop at fault | Cause | Action |
|---|---|---|---|
| Connection refused, or no response | Gateway to Flask | Flask not running, port 9876 taken, local firewall rule | Run netstat -ano | findstr 9876 on the Gateway host, then restart Flask |
Missing required parameters (400) |
Request body | Body not sent as JSON, or keys other than path / file_path
|
Use httpClient().post with a dict for data
|
Dropbox authentication failed (401) |
Flask token load | Relative TOKEN_FILE not found, wrong App Secret, revoked refresh token |
Use an absolute token path and re-run the auth script |
| Dropbox error naming a missing scope | Dropbox app | Permissions added after authorization | Submit the scopes, then re-authorize |
No such file or directory in the error |
Staging folder | Flask on another host, folder missing, or Flask account lacks read access | Run the folder checks from the first section again |
file_path outside upload dir |
Flask path guard | Library and Flask disagree on the upload folder | Match in both |
| Earlier version gone in Dropbox | Upload mode | WriteMode('overwrite') |
Switch to add mode with auto-rename, or timestamp the file name |
| Download button: no visible effect | Button script | Return value discarded | Pass the bytes to system.perspective.download
|
How do you prove the whole chain end to end?
- Restart Flask from a different working directory than
app.py. A clean start shows the absoluteTOKEN_FILEfix is in place. - Confirm
debug=Falseand that Flask listens only on127.0.0.1:9876. - Replay the curl test, then send a
file_pathoutside the upload folder. Expect the 400 rejection. - From a Perspective session on a PC other than the Gateway, upload a text file and a binary file. Expect the success label, both files in Dropbox, and an empty upload folder.
- Upload the same file name again and confirm the Dropbox result matches the write mode you chose.
- Leave Flask running longer than the access-token lifetime, then upload again with nobody logged in on the Gateway host. A success confirms that the refresh token renews the session with no operator involved.
- Download the binary file through the button on the remote session and open it. A clean open confirms every hop, from browser to Gateway to Flask to Dropbox and back, carries bytes intact.
FAQ
Can I call the Dropbox API directly from Ignition scripting without Flask?
The Dropbox Python SDK does not load in Ignition's Jython engine, which is why this build uses a Flask service. You could call the Dropbox HTTP endpoints with system.net.httpClient(), but then you have to implement the OAuth refresh-token exchange yourself.
Does the Flask API have to run on the same server as the Ignition Gateway?
With the file-path handoff, yes. Flask opens the staged file from C:/temp/dropbox_upload, so it needs the same filesystem. To separate the hosts, use a shared folder or send the file bytes in the request, bind Flask off loopback, and add TLS.
Does the upload work when the Perspective session runs on a different PC?
Yes. onFileReceived runs on the Gateway, so event.file.copyTo() writes to the Gateway host and 127.0.0.1:9876 resolves to the Gateway host no matter where the browser is.
Can I test dropbox_rest from the Designer Script Console?
Only if the Designer runs on the Gateway host. The console runs in designer scope, so 127.0.0.1 points at the local machine, and it has no upload event object. Test from a live session and read the Dropbox logger in the Gateway logs.
The short-lived access token expired and the service had no working refresh path. Authorize with token_access_type='offline', build the client with oauth2_refresh_token, app_key and app_secret, and load dropbox_token.json from an absolute path.