ivoryos 1.0.0__tar.gz → 1.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ivoryos might be problematic. Click here for more details.

Files changed (59) hide show
  1. {ivoryos-1.0.0/ivoryos.egg-info → ivoryos-1.0.3}/PKG-INFO +3 -3
  2. {ivoryos-1.0.0 → ivoryos-1.0.3}/README.md +2 -2
  3. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/routes/auth/auth.py +3 -3
  4. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/routes/control/control.py +89 -60
  5. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/routes/control/templates/control/controllers_home.html +7 -7
  6. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/routes/database/database.py +98 -39
  7. ivoryos-1.0.0/ivoryos/routes/database/templates/database/experiment_database.html → ivoryos-1.0.3/ivoryos/routes/database/templates/database/scripts_database.html +27 -18
  8. ivoryos-1.0.0/ivoryos/routes/database/templates/database/workflow_run_database.html → ivoryos-1.0.3/ivoryos/routes/database/templates/database/workflow_database.html +27 -5
  9. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/routes/design/design.py +215 -78
  10. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/routes/design/templates/design/experiment_run.html +62 -123
  11. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/static/js/socket_handler.js +13 -8
  12. ivoryos-1.0.3/ivoryos/utils/bo_campaign.py +87 -0
  13. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/utils/client_proxy.py +1 -1
  14. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/utils/db_models.py +27 -7
  15. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/utils/global_config.py +16 -7
  16. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/utils/script_runner.py +56 -40
  17. ivoryos-1.0.3/ivoryos/utils/task_runner.py +81 -0
  18. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/utils/utils.py +0 -68
  19. ivoryos-1.0.3/ivoryos/version.py +1 -0
  20. {ivoryos-1.0.0 → ivoryos-1.0.3/ivoryos.egg-info}/PKG-INFO +3 -3
  21. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos.egg-info/SOURCES.txt +5 -3
  22. ivoryos-1.0.0/ivoryos/version.py +0 -1
  23. {ivoryos-1.0.0 → ivoryos-1.0.3}/LICENSE +0 -0
  24. {ivoryos-1.0.0 → ivoryos-1.0.3}/MANIFEST.in +0 -0
  25. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/__init__.py +0 -0
  26. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/config.py +0 -0
  27. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/routes/__init__.py +0 -0
  28. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/routes/auth/__init__.py +0 -0
  29. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/routes/auth/templates/auth/login.html +0 -0
  30. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/routes/auth/templates/auth/signup.html +0 -0
  31. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/routes/control/__init__.py +0 -0
  32. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/routes/control/templates/control/controllers.html +0 -0
  33. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/routes/control/templates/control/controllers_new.html +0 -0
  34. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/routes/database/__init__.py +0 -0
  35. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/routes/database/templates/database/step_card.html +0 -0
  36. /ivoryos-1.0.0/ivoryos/routes/database/templates/database/experiment_step_view.html → /ivoryos-1.0.3/ivoryos/routes/database/templates/database/workflow_view.html +0 -0
  37. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/routes/design/__init__.py +0 -0
  38. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/routes/design/templates/design/experiment_builder.html +0 -0
  39. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/routes/main/__init__.py +0 -0
  40. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/routes/main/main.py +0 -0
  41. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/routes/main/templates/main/help.html +0 -0
  42. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/routes/main/templates/main/home.html +0 -0
  43. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/static/favicon.ico +0 -0
  44. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/static/gui_annotation/Slide1.png +0 -0
  45. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/static/gui_annotation/Slide2.PNG +0 -0
  46. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/static/js/overlay.js +0 -0
  47. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/static/js/sortable_card.js +0 -0
  48. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/static/js/sortable_design.js +0 -0
  49. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/static/logo.webp +0 -0
  50. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/static/style.css +0 -0
  51. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/templates/base.html +0 -0
  52. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/utils/__init__.py +0 -0
  53. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/utils/form.py +0 -0
  54. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos/utils/llm_agent.py +0 -0
  55. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos.egg-info/dependency_links.txt +0 -0
  56. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos.egg-info/requires.txt +0 -0
  57. {ivoryos-1.0.0 → ivoryos-1.0.3}/ivoryos.egg-info/top_level.txt +0 -0
  58. {ivoryos-1.0.0 → ivoryos-1.0.3}/setup.cfg +0 -0
  59. {ivoryos-1.0.0 → ivoryos-1.0.3}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ivoryos
3
- Version: 1.0.0
3
+ Version: 1.0.3
4
4
  Summary: an open-source Python package enabling Self-Driving Labs (SDLs) interoperability
5
5
  Home-page: https://gitlab.com/heingroup/ivoryos
6
6
  Author: Ivory Zhang
@@ -14,8 +14,8 @@ License-File: LICENSE
14
14
  [![PyPI version](https://img.shields.io/pypi/v/ivoryos)](https://pypi.org/project/ivoryos/)
15
15
  ![License](https://img.shields.io/pypi/l/ivoryos)
16
16
  [![YouTube](https://img.shields.io/badge/YouTube-video-red?logo=youtube)](https://youtu.be/dFfJv9I2-1g)
17
- [![Published](https://img.shields.io/badge/Nature_Communications-paper-blue)](https://www.nature.com/articles/s41467-025-60514-w)
18
-
17
+ [![Published](https://img.shields.io/badge/Nature_Comm.-paper-blue)](https://www.nature.com/articles/s41467-025-60514-w)
18
+ [![Discord](https://img.shields.io/discord/1313641159356059770?label=Discord&logo=discord&color=5865F2)](https://discord.gg/AX5P9EdGVX)
19
19
 
20
20
  ![](https://gitlab.com/heingroup/ivoryos/raw/main/docs/source/_static/ivoryos.png)
21
21
  # ivoryOS: interoperable Web UI for self-driving laboratories (SDLs)
@@ -2,8 +2,8 @@
2
2
  [![PyPI version](https://img.shields.io/pypi/v/ivoryos)](https://pypi.org/project/ivoryos/)
3
3
  ![License](https://img.shields.io/pypi/l/ivoryos)
4
4
  [![YouTube](https://img.shields.io/badge/YouTube-video-red?logo=youtube)](https://youtu.be/dFfJv9I2-1g)
5
- [![Published](https://img.shields.io/badge/Nature_Communications-paper-blue)](https://www.nature.com/articles/s41467-025-60514-w)
6
-
5
+ [![Published](https://img.shields.io/badge/Nature_Comm.-paper-blue)](https://www.nature.com/articles/s41467-025-60514-w)
6
+ [![Discord](https://img.shields.io/discord/1313641159356059770?label=Discord&logo=discord&color=5865F2)](https://discord.gg/AX5P9EdGVX)
7
7
 
8
8
  ![](https://gitlab.com/heingroup/ivoryos/raw/main/docs/source/_static/ivoryos.png)
9
9
  # ivoryOS: interoperable Web UI for self-driving laboratories (SDLs)
@@ -9,7 +9,7 @@ login_manager = LoginManager()
9
9
  auth = Blueprint('auth', __name__, template_folder='templates/auth')
10
10
 
11
11
 
12
- @auth.route('/login', methods=['GET', 'POST'])
12
+ @auth.route('/auth/login', methods=['GET', 'POST'])
13
13
  def login():
14
14
  """
15
15
  .. :quickref: User; login user
@@ -50,7 +50,7 @@ def login():
50
50
  return render_template('login.html')
51
51
 
52
52
 
53
- @auth.route('/signup', methods=['GET', 'POST'])
53
+ @auth.route('/auth/signup', methods=['GET', 'POST'])
54
54
  def signup():
55
55
  """
56
56
  .. :quickref: User; signup for a new account
@@ -82,7 +82,7 @@ def signup():
82
82
  return render_template('signup.html')
83
83
 
84
84
 
85
- @auth.route("/logout")
85
+ @auth.route("/auth/logout")
86
86
  @login_required
87
87
  def logout():
88
88
  """
@@ -1,18 +1,22 @@
1
1
  import os
2
2
 
3
- from flask import Blueprint, redirect, url_for, flash, request, render_template, session, current_app, jsonify
3
+ from flask import Blueprint, redirect, url_for, flash, request, render_template, session, current_app, jsonify, \
4
+ send_file
4
5
  from flask_login import login_required
5
6
 
7
+ from ivoryos.utils.client_proxy import export_to_python, create_function
6
8
  from ivoryos.utils.global_config import GlobalConfig
7
9
  from ivoryos.utils import utils
8
10
  from ivoryos.utils.form import create_form_from_module, format_name
11
+ from ivoryos.utils.task_runner import TaskRunner
9
12
 
10
13
  global_config = GlobalConfig()
14
+ runner = TaskRunner()
11
15
 
12
16
  control = Blueprint('control', __name__, template_folder='templates/control')
13
17
 
14
18
 
15
- @control.route("/my_deck")
19
+ @control.route("/control/home/deck", strict_slashes=False)
16
20
  @login_required
17
21
  def deck_controllers():
18
22
  """
@@ -20,15 +24,15 @@ def deck_controllers():
20
24
 
21
25
  deck control home interface for listing all deck instruments
22
26
 
23
- .. http:get:: /my_deck
27
+ .. http:get:: /control/home/deck
24
28
  """
25
29
  deck_variables = global_config.deck_snapshot.keys()
26
30
  deck_list = utils.import_history(os.path.join(current_app.config["OUTPUT_FOLDER"], 'deck_history.txt'))
27
31
  return render_template('controllers_home.html', defined_variables=deck_variables, deck=True, history=deck_list)
28
32
 
29
33
 
30
- @control.route("/new_controller/")
31
- @control.route("/new_controller/<instrument>", methods=['GET', 'POST'])
34
+ @control.route("/control/new/", strict_slashes=False)
35
+ @control.route("/control/new/<instrument>", methods=['GET', 'POST'])
32
36
  @login_required
33
37
  def new_controller(instrument=None):
34
38
  """
@@ -36,12 +40,12 @@ def new_controller(instrument=None):
36
40
 
37
41
  interface for connecting a new <instrument>
38
42
 
39
- .. http:get:: /new_controller
43
+ .. http:get:: /control/new/
40
44
 
41
45
  :param instrument: instrument name
42
46
  :type instrument: str
43
47
 
44
- .. http:post:: /new_controller
48
+ .. http:post:: /control/new/
45
49
 
46
50
  :form device_name: module instance name (e.g. my_instance = MyClass())
47
51
  :form kwargs: dynamic module initialization kwargs fields
@@ -86,7 +90,7 @@ def new_controller(instrument=None):
86
90
  device=device, args=args, defined_variables=global_config.defined_variables)
87
91
 
88
92
 
89
- @control.route("/controllers")
93
+ @control.route("/control/home/temp", strict_slashes=False)
90
94
  @login_required
91
95
  def controllers_home():
92
96
  """
@@ -94,14 +98,15 @@ def controllers_home():
94
98
 
95
99
  temporarily connected devices home interface for listing all instruments
96
100
 
97
- .. http:get:: /controllers
101
+ .. http:get:: /control/home/temp
98
102
 
99
103
  """
100
104
  # defined_variables = parse_deck(deck)
101
- return render_template('controllers_home.html', defined_variables=global_config.defined_variables)
105
+ defined_variables = global_config.defined_variables.keys()
106
+ return render_template('controllers_home.html', defined_variables=defined_variables)
102
107
 
103
108
 
104
- @control.route("/controllers/<instrument>", methods=['GET', 'POST'])
109
+ @control.route("/control/<instrument>/methods", methods=['GET', 'POST'])
105
110
  @login_required
106
111
  def controllers(instrument: str):
107
112
  """
@@ -109,12 +114,12 @@ def controllers(instrument: str):
109
114
 
110
115
  control interface for selected <instrument>
111
116
 
112
- .. http:get:: /controllers
117
+ .. http:get:: /control/<instrument>/methods
113
118
 
114
119
  :param instrument: instrument name
115
120
  :type instrument: str
116
121
 
117
- .. http:post:: /controllers
122
+ .. http:post:: /control/<instrument>/methods
118
123
 
119
124
  :form hidden_name: function name (hidden field)
120
125
  :form kwargs: dynamic kwargs field
@@ -142,7 +147,9 @@ def controllers(instrument: str):
142
147
  if form and form.validate_on_submit():
143
148
  try:
144
149
  kwargs.pop("hidden_name")
145
- output = function_executable(**kwargs)
150
+ output = runner.run_single_step(instrument, method_name, kwargs, wait=True,
151
+ current_app=current_app._get_current_object())
152
+ # output = function_executable(**kwargs)
146
153
  flash(f"\nRun Success! Output value: {output}.")
147
154
  except Exception as e:
148
155
  flash(e.__str__())
@@ -150,81 +157,103 @@ def controllers(instrument: str):
150
157
  flash(form.errors)
151
158
  return render_template('controllers.html', instrument=instrument, forms=forms, format_name=format_name)
152
159
 
160
+ @control.route("/control/download", strict_slashes=False)
161
+ @login_required
162
+ def download_proxy():
163
+ """
164
+ .. :quickref: Direct Control; download proxy interface
153
165
 
154
- @control.route("/backend_control/<instrument>", methods=['POST'])
166
+ download proxy interface
167
+
168
+ .. http:get:: /control/download
169
+ """
170
+ snapshot = global_config.deck_snapshot.copy()
171
+ class_definitions = {}
172
+ # Iterate through each instrument in the snapshot
173
+ for instrument_key, instrument_data in snapshot.items():
174
+ # Iterate through each function associated with the current instrument
175
+ for function_key, function_data in instrument_data.items():
176
+ # Convert the function signature to a string representation
177
+ function_data['signature'] = str(function_data['signature'])
178
+ class_name = instrument_key.split('.')[-1] # Extracting the class name from the path
179
+ class_definitions[class_name.capitalize()] = create_function(request.url_root, class_name, instrument_data)
180
+ # Export the generated class definitions to a .py script
181
+ export_to_python(class_definitions, current_app.config["OUTPUT_FOLDER"])
182
+ filepath = os.path.join(current_app.config["OUTPUT_FOLDER"], "generated_proxy.py")
183
+ return send_file(os.path.abspath(filepath), as_attachment=True)
184
+
185
+ @control.route("/api/control/", strict_slashes=False, methods=['GET'])
186
+ @control.route("/api/control/<instrument>", methods=['POST'])
155
187
  def backend_control(instrument: str=None):
156
188
  """
157
189
  .. :quickref: Backend Control; backend control
158
190
 
159
191
  backend control through http requests
160
192
 
161
- .. http:get:: /backend_control
193
+ .. http:get:: /api/control/
162
194
 
163
195
  :param instrument: instrument name
164
196
  :type instrument: str
165
197
 
166
- .. http:post:: /backend_control
198
+ .. http:post:: /api/control/
167
199
 
168
200
  """
169
- inst_object = find_instrument_by_name(instrument)
170
- forms = create_form_from_module(sdl_module=inst_object, autofill=False, design=False)
201
+ if instrument:
202
+ inst_object = find_instrument_by_name(instrument)
203
+ forms = create_form_from_module(sdl_module=inst_object, autofill=False, design=False)
171
204
 
172
205
  if request.method == 'POST':
173
- all_kwargs = request.form.copy()
174
- method_name = all_kwargs.pop("hidden_name", None)
175
- # if method_name is not None:
206
+ method_name = request.form.get("hidden_name", None)
176
207
  form = forms.get(method_name, None)
177
- kwargs = {field.name: field.data for field in form if field.name != 'csrf_token'}
178
- function_executable = getattr(inst_object, method_name)
179
208
  if form:
180
- # print(kwargs)
181
- try:
182
- kwargs.pop("hidden_name")
183
- output = function_executable(**kwargs)
184
- json_output = jsonify(output)
185
- except Exception as e:
186
- json_output = jsonify(e.__str__())
187
- return json_output, 400
188
- else:
189
- return "instrument not exist", 400
190
- return json_output, 200
191
-
192
-
193
- @control.route("/backend_control", methods=['GET'])
194
- def backend_client():
195
- """
196
- .. :quickref: Backend Control; get snapshot
197
-
198
- backend control through http requests
209
+ kwargs = {field.name: field.data for field in form if field.name not in ['csrf_token', 'hidden_name']}
210
+ wait = request.form.get("hidden_wait", "true") == "true"
211
+ output = runner.run_single_step(component=instrument, method=method_name, kwargs=kwargs, wait=wait,
212
+ current_app=current_app._get_current_object())
213
+ return jsonify(output), 200
199
214
 
200
- .. http:get:: /backend_control
201
- """
202
- # Create a snapshot of the current deck configuration
203
215
  snapshot = global_config.deck_snapshot.copy()
204
-
205
216
  # Iterate through each instrument in the snapshot
206
217
  for instrument_key, instrument_data in snapshot.items():
207
218
  # Iterate through each function associated with the current instrument
208
219
  for function_key, function_data in instrument_data.items():
209
220
  # Convert the function signature to a string representation
210
221
  function_data['signature'] = str(function_data['signature'])
222
+ return jsonify(snapshot), 200
211
223
 
212
- json_output = jsonify(snapshot)
213
- return json_output, 200
224
+ # @control.route("/api/control", strict_slashes=False, methods=['GET'])
225
+ # def backend_client():
226
+ # """
227
+ # .. :quickref: Backend Control; get snapshot
228
+ #
229
+ # backend control through http requests
230
+ #
231
+ # .. http:get:: /api/control/summary
232
+ # """
233
+ # # Create a snapshot of the current deck configuration
234
+ # snapshot = global_config.deck_snapshot.copy()
235
+ #
236
+ # # Iterate through each instrument in the snapshot
237
+ # for instrument_key, instrument_data in snapshot.items():
238
+ # # Iterate through each function associated with the current instrument
239
+ # for function_key, function_data in instrument_data.items():
240
+ # # Convert the function signature to a string representation
241
+ # function_data['signature'] = str(function_data['signature'])
242
+ # return jsonify(snapshot), 200
214
243
 
215
244
 
216
- @control.route("/import_api", methods=['POST'])
245
+ @control.route("/control/import/module", methods=['POST'])
217
246
  def import_api():
218
247
  """
219
248
  .. :quickref: Advanced Features; Manually import API module(s)
220
249
 
221
250
  importing other Python modules
222
251
 
223
- .. http:post:: /import_api
252
+ .. http:post:: /control/import/module
224
253
 
225
254
  :form filepath: API (Python class) module filepath
226
255
 
227
- import the module and redirect to :http:get:`/ivoryos/new_controller/`
256
+ import the module and redirect to :http:get:`/ivoryos/control/new/`
228
257
 
229
258
  """
230
259
  filepath = request.form.get('filepath')
@@ -272,12 +301,12 @@ def import_api():
272
301
  # return redirect(url_for('control.deck_controllers'))
273
302
 
274
303
 
275
- @control.route("/import_deck", methods=['POST'])
304
+ @control.route("/control/import/deck", methods=['POST'])
276
305
  def import_deck():
277
306
  """
278
307
  .. :quickref: Advanced Features; Manually import a deck
279
308
 
280
- .. http:post:: /import_deck
309
+ .. http:post:: /control/import_deck
281
310
 
282
311
  :form filepath: deck module filepath
283
312
 
@@ -310,12 +339,12 @@ def import_deck():
310
339
  return redirect(back)
311
340
 
312
341
 
313
- @control.route('/save-order/<instrument>', methods=['POST'])
342
+ @control.route('/control/<instrument>/save-order', methods=['POST'])
314
343
  def save_order(instrument: str):
315
344
  """
316
345
  .. :quickref: Control Customization; Save functions' order
317
346
 
318
- .. http:post:: /save-order
347
+ .. http:post:: /control/save-order
319
348
 
320
349
  save function drag and drop order for the given <instrument>
321
350
 
@@ -326,12 +355,12 @@ def save_order(instrument: str):
326
355
  return '', 204
327
356
 
328
357
 
329
- @control.route('/hide_function/<instrument>/<function>')
358
+ @control.route('/control/<instrument>/<function>/hide')
330
359
  def hide_function(instrument, function):
331
360
  """
332
361
  .. :quickref: Control Customization; Hide function
333
362
 
334
- .. http:get:: /hide_function
363
+ .. http:get:: //control/<instrument>/<function>/hide
335
364
 
336
365
  Hide the given <instrument> and <function>
337
366
 
@@ -347,12 +376,12 @@ def hide_function(instrument, function):
347
376
  return redirect(back)
348
377
 
349
378
 
350
- @control.route('/remove_hidden/<instrument>/<function>')
379
+ @control.route('/control/<instrument>/<function>/unhide')
351
380
  def remove_hidden(instrument: str, function: str):
352
381
  """
353
382
  .. :quickref: Control Customization; Remove a hidden function
354
383
 
355
- .. http:get:: /remove_hidden
384
+ .. http:get:: /control/<instrument>/<function>/unhide
356
385
 
357
386
  Un-hide the given <instrument> and <function>
358
387
 
@@ -6,21 +6,21 @@
6
6
  {% for instrument in defined_variables %}
7
7
  <div class="col-xl-3 col-lg-4 col-md-6 mb-4 ">
8
8
  <div class="bg-white rounded shadow-sm position-relative">
9
- {# {% if not deck %}#}
9
+ {% if deck %}
10
10
  {# <a href="{{ url_for('control.disconnect', instrument=instrument) }}" class="stretched-link controller-card" style="float: right;color: red; position: relative;">Disconnect <i class="bi bi-x-square"></i></a>#}
11
11
  <div class="p-4 controller-card">
12
12
  <h5 class=""><a href="{{ url_for('control.controllers', instrument=instrument) }}" class="text-dark stretched-link">{{instrument.split(".")[1]}}</a></h5>
13
13
  </div>
14
- {# {% else %}#}
15
- {# <div class="p-4 controller-card">#}
16
- {# <h5 class=""><a href="{{ url_for('control.controllers', instrument=instrument) }}" class="text-dark stretched-link">{{instrument}}</a></h5>#}
17
- {# </div>#}
18
- {# {% endif %}#}
14
+ {% else %}
15
+ <div class="p-4 controller-card">
16
+ <h5 class=""><a href="{{ url_for('control.controllers', instrument=instrument) }}" class="text-dark stretched-link">{{instrument}}</a></h5>
17
+ </div>
18
+ {% endif %}
19
19
  </div>
20
20
  </div>
21
21
  {% endfor %}
22
22
  <div class="d-flex mb-3">
23
- <a href="{{ url_for('design.download', filetype='proxy') }}" class="btn btn-outline-primary">
23
+ <a href="{{ url_for('control.download_proxy', filetype='proxy') }}" class="btn btn-outline-primary">
24
24
  <i class="bi bi-download"></i> Download remote control script
25
25
  </a>
26
26
  </div>