From f13bd6e8106eddf57ba6823768a90012e0f3f4ce Mon Sep 17 00:00:00 2001
From: Kirby Kalbaugh
Date: Tue, 7 Apr 2026 14:49:18 -0400
Subject: [PATCH] added globus
---
README.md | 31 +++++
setup.py | 2 +-
spiro/config.py | 45 ++++++-
spiro/experimenter.py | 9 ++
spiro/globus_local.py | 136 +++++++++++++++++++
spiro/templates/experiment.html | 28 +++-
spiro/templates/settings.html | 111 ++++++++++++++++
spiro/transfer.py | 207 +++++++++++++++++++++++++++++
spiro/webui.py | 223 +++++++++++++++++++++++++++++++-
9 files changed, 786 insertions(+), 6 deletions(-)
create mode 100644 spiro/globus_local.py
create mode 100644 spiro/transfer.py
diff --git a/README.md b/README.md
index 3e836a7..81ce73f 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,7 @@ The image below is a link to a YouTube video showing some of its design and feat
* [Connecting to the web interface](#connecting-to-the-web-interface)
* [Setting up imaging](#setting-up-imaging)
* [Starting an experiment](#starting-an-experiment)
+ * [Globus transfer during experiments](#globus-transfer-during-experiments)
* [Downloading images](#downloading-images)
* [Maintaining the system](#maintaining-the-system)
* [Restarting the software](#restarting-the-software)
@@ -235,6 +236,36 @@ For locating the initial imaging position, the system turns the cube until a pos
When imaging parameters are set up to your liking, you are ready to start your experiments. In the *Experiment control* view, choose a name for your experiment, as well as the duration and imaging frequency. After you choose *Start experiment*, the system will disable most of the functionality of the web interface, displaying a simple status window containing experiment parameters, as well as the last image captured.
+### Globus transfer during experiments
+
+SPIRO can transfer newly captured files to Globus while an experiment runs.
+
+On a Raspberry Pi running Raspberry Pi OS, install the local source endpoint software first:
+
+```bash
+sudo apt update
+sudo apt install -y wget python3-pip tcl tk
+python3 -m pip install --user globus-cli
+wget https://downloads.globus.org/globus-connect-personal/linux/stable/globusconnectpersonal-latest.tgz
+tar xzf globusconnectpersonal-latest.tgz
+mv globusconnectpersonal-* ~/.globusconnectpersonal
+```
+
+After installation:
+
+1. Open *System settings* and set your *Globus Client ID*.
+2. In *Local source endpoint*, use *Launch setup/login* to start Globus Connect Personal setup on the SPIRO device.
+3. If the Pi is headless, complete the first-time setup from a local shell by running `~/.globusconnectpersonal/globusconnectpersonal` or `~/.globusconnectpersonal/globusconnectpersonal -setup`.
+4. Use *Start local client* once Globus Connect Personal is configured.
+5. Use *Refresh local endpoint ID* to detect the SPIRO device's source endpoint via `globus endpoint local-id`.
+6. Click *Connect Globus* to authorize the SPIRO web app to submit transfer tasks.
+7. Copy the authorization code from Globus and paste it into *Complete Globus Login*.
+8. In *Experiment control*, enable transfer and provide only the destination endpoint ID and destination base path for this run.
+
+SPIRO now uses the local Globus Connect Personal endpoint on the device as the source endpoint automatically. The source path is set to the experiment directory for the active run.
+
+Transfers are queued once per imaging cycle and submitted in the background. If a transfer fails, SPIRO keeps imaging and retries automatically.
+
### Downloading images
Images can be downloaded from the web interface under *File manager*. The File manager also allows deleting files to free up space on the SD card.
diff --git a/setup.py b/setup.py
index 64d7a43..5aeee86 100644
--- a/setup.py
+++ b/setup.py
@@ -24,7 +24,7 @@ def get_version_and_cmdclass(package_path):
cmdclass = cmdclass,
packages = find_packages(),
scripts = ['bin/spiro'],
- install_requires = ['picamera2==0.3.31', 'RPi.GPIO==0.7.1', 'Flask==2.2.5', 'waitress==2.1.2', 'numpy==1.24.2', 'Werkzeug==2.2.3'],
+ install_requires = ['picamera2==0.3.31', 'RPi.GPIO==0.7.1', 'Flask==2.2.5', 'waitress==2.1.2', 'numpy==1.24.2', 'Werkzeug==2.2.3', 'globus-sdk>=3.39.0', 'cryptography>=42.0.0'],
author = 'Kirby Kalbaugh',
author_email = 'kkalbaug@purdue.edu',
description = 'Control software for the SPIRO biological imaging system',
diff --git a/spiro/config.py b/spiro/config.py
index 7eea0a4..2b6f09c 100644
--- a/spiro/config.py
+++ b/spiro/config.py
@@ -5,6 +5,7 @@
import json
import os
import sys
+from cryptography.fernet import Fernet
from importlib import metadata
from ._version import __version__
@@ -55,7 +56,13 @@ class Config(object):
'name': 'spiro', # the name of this spiro instance
'timezone': 'America/New_York', # user display/save timezone; device clock remains UTC
'debug': True, # debug logging
- 'rotated_camera': True # rotated camera house
+ 'rotated_camera': True, # rotated camera house
+ 'globus_client_id': '',
+ 'globus_token_encrypted': '',
+ 'globus_gcp_install_dir': '~/.globusconnectpersonal',
+ 'globus_local_endpoint_id': '',
+ 'globus_source_endpoint': '',
+ 'globus_source_base_path': '/'
}
config = {}
@@ -63,6 +70,7 @@ class Config(object):
def __init__(self):
self.cfgdir = os.path.expanduser("~/.config/spiro")
self.cfgfile = os.path.join(self.cfgdir, "spiro.conf")
+ self.keyfile = os.path.join(self.cfgdir, "token.key")
self.version = _resolve_version()
self.read()
if os.path.exists(self.cfgfile):
@@ -87,9 +95,44 @@ def write(self):
with open(self.cfgfile + ".tmp", 'w') as f:
json.dump(self.config, f, indent=4)
os.replace(self.cfgfile + ".tmp", self.cfgfile)
+ os.chmod(self.cfgfile, 0o600)
except OSError as e:
log("Failed to write config file: " + e.strerror)
+
+ def _get_or_create_secret_key(self):
+ os.makedirs(self.cfgdir, exist_ok=True)
+ if os.path.exists(self.keyfile):
+ with open(self.keyfile, 'rb') as f:
+ key = f.read().strip()
+ if key:
+ return key
+
+ key = Fernet.generate_key()
+ with open(self.keyfile + '.tmp', 'wb') as f:
+ f.write(key)
+ os.replace(self.keyfile + '.tmp', self.keyfile)
+ os.chmod(self.keyfile, 0o600)
+ return key
+
+
+ def encrypt_secret(self, value):
+ if value in [None, '']:
+ return ''
+ cipher = Fernet(self._get_or_create_secret_key())
+ return cipher.encrypt(value.encode('utf-8')).decode('utf-8')
+
+
+ def decrypt_secret(self, value):
+ if value in [None, '']:
+ return ''
+ try:
+ cipher = Fernet(self._get_or_create_secret_key())
+ return cipher.decrypt(value.encode('utf-8')).decode('utf-8')
+ except Exception as e:
+ log('Failed to decrypt secret: ' + str(e))
+ return ''
+
def get(self, key):
if os.path.exists(self.cfgfile):
st = os.stat(self.cfgfile)
diff --git a/spiro/experimenter.py b/spiro/experimenter.py
index 0092757..5aa4e71 100644
--- a/spiro/experimenter.py
+++ b/spiro/experimenter.py
@@ -37,6 +37,7 @@ def __init__(self, hw=None, cam=None):
self.preview_lock = threading.Lock()
self.nshots = 0
self.idlepos = 0
+ self.transfer_cycle_handler = None
threading.Thread.__init__(self)
@@ -241,6 +242,7 @@ def runExperiment(self):
nextloop = time.time() + 60 * self.delay
if nextloop > self.endtime:
nextloop = self.endtime
+ cycle_files = []
self.hw.motorOn(True)
for i in range(self.position_count):
@@ -267,11 +269,18 @@ def runExperiment(self):
now = filename_timestamp(self.cfg.get('timezone'))
name = os.path.join("plate" + str(i + 1), "plate" + str(i + 1) + "-" + now)
self.takePicture(name, i)
+ cycle_files.append(self.last_captured[i])
if self.stop_experiment:
self.hw.motorOn(False)
break
+ if self.transfer_cycle_handler and cycle_files:
+ try:
+ self.transfer_cycle_handler(cycle_files)
+ except Exception as e:
+ debug('Failed to queue transfer cycle files: ' + str(e))
+
self.nshots -= 1
self.hw.motorOn(False)
if self.status != "Stopping": self.status = "Waiting"
diff --git a/spiro/globus_local.py b/spiro/globus_local.py
new file mode 100644
index 0000000..abb8fe8
--- /dev/null
+++ b/spiro/globus_local.py
@@ -0,0 +1,136 @@
+import os
+import re
+import shutil
+import subprocess
+
+from spiro.logger import debug
+
+
+class LocalGlobusConnectPersonal(object):
+ def __init__(self, cfg):
+ self.cfg = cfg
+
+
+ def install_dir(self):
+ return os.path.expanduser(self.cfg.get('globus_gcp_install_dir') or '~/.globusconnectpersonal')
+
+
+ def binary_path(self):
+ return os.path.join(self.install_dir(), 'globusconnectpersonal')
+
+
+ def is_installed(self):
+ return os.path.isfile(self.binary_path())
+
+
+ def is_configured(self):
+ return os.path.isdir(os.path.expanduser('~/.globusonline'))
+
+
+ def cli_path(self):
+ return shutil.which('globus')
+
+
+ def launch_setup_or_login(self):
+ if not self.is_installed():
+ raise RuntimeError('Globus Connect Personal is not installed. Follow the README setup instructions first.')
+
+ subprocess.Popen(
+ [self.binary_path()],
+ cwd=self.install_dir(),
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ )
+
+
+ def start_background(self):
+ if not self.is_installed():
+ raise RuntimeError('Globus Connect Personal is not installed. Follow the README setup instructions first.')
+
+ result = subprocess.run(
+ [self.binary_path(), '-start'],
+ cwd=self.install_dir(),
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ if result.returncode != 0:
+ details = (result.stderr or result.stdout or 'Unable to start Globus Connect Personal.').strip()
+ raise RuntimeError(details)
+ return result.stdout.strip()
+
+
+ def status(self, refresh_endpoint=True):
+ info = {
+ 'install_dir': self.install_dir(),
+ 'binary_path': self.binary_path(),
+ 'installed': self.is_installed(),
+ 'configured': self.is_configured(),
+ 'running': False,
+ 'connected': False,
+ 'status_text': 'Not installed',
+ 'endpoint_id': self.cfg.get('globus_local_endpoint_id') or '',
+ 'cli_available': bool(self.cli_path()),
+ }
+
+ if not info['installed']:
+ return info
+
+ try:
+ result = subprocess.run(
+ [self.binary_path(), '-status'],
+ cwd=self.install_dir(),
+ capture_output=True,
+ text=True,
+ timeout=15,
+ )
+ output = (result.stdout or result.stderr or '').strip()
+ if result.returncode == 0:
+ info['running'] = True
+ info['status_text'] = output or 'Running'
+ info['connected'] = 'globus online: connected' in output.lower()
+ elif info['configured']:
+ info['status_text'] = output or 'Installed but not running'
+ else:
+ info['status_text'] = 'Installed but not configured'
+ except Exception as e:
+ debug('Failed to read Globus Connect Personal status: ' + str(e))
+ if info['configured']:
+ info['status_text'] = 'Installed but status is unavailable'
+
+ if refresh_endpoint:
+ endpoint_id = self.refresh_local_endpoint_id()
+ if endpoint_id:
+ info['endpoint_id'] = endpoint_id
+
+ return info
+
+
+ def refresh_local_endpoint_id(self):
+ cli = self.cli_path()
+ if not cli:
+ return self.cfg.get('globus_local_endpoint_id') or ''
+
+ try:
+ result = subprocess.run(
+ [cli, 'endpoint', 'local-id'],
+ capture_output=True,
+ text=True,
+ timeout=15,
+ )
+ except Exception as e:
+ debug('Failed to query local Globus endpoint id: ' + str(e))
+ return self.cfg.get('globus_local_endpoint_id') or ''
+
+ if result.returncode != 0:
+ return self.cfg.get('globus_local_endpoint_id') or ''
+
+ endpoint_id = (result.stdout or '').strip().splitlines()
+ endpoint_id = endpoint_id[-1].strip() if endpoint_id else ''
+ if re.fullmatch(r'[0-9a-fA-F-]{36}', endpoint_id):
+ self.cfg.set('globus_local_endpoint_id', endpoint_id)
+ self.cfg.set('globus_source_endpoint', endpoint_id)
+ return endpoint_id
+
+ return self.cfg.get('globus_local_endpoint_id') or ''
\ No newline at end of file
diff --git a/spiro/templates/experiment.html b/spiro/templates/experiment.html
index 0eae033..3dea97e 100644
--- a/spiro/templates/experiment.html
+++ b/spiro/templates/experiment.html
@@ -49,7 +49,11 @@