openc3-cosmos-script-engine-cstol 1.1.0 → 1.2.0

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.
Files changed (6) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +100 -8
  3. data/lib/cstol_script_engine.py +599 -402
  4. data/pyproject.toml +83 -0
  5. data/uv.lock +1396 -0
  6. metadata +4 -2
@@ -20,15 +20,72 @@
20
20
  #
21
21
  # CSTOL language originally developed by the University of Colorado / LASP.
22
22
 
23
- import re
24
- import shlex
25
23
  import datetime
26
24
  import math
27
25
  import os
26
+ import re
27
+
28
+ from openc3.script import (
29
+ ask_string,
30
+ clear_all_screens,
31
+ clear_screen,
32
+ cmd,
33
+ connect_interface,
34
+ disconnect_interface,
35
+ display_screen,
36
+ get_target_file,
37
+ run_mode,
38
+ send_raw,
39
+ set_line_delay,
40
+ set_tlm,
41
+ start,
42
+ step_mode,
43
+ tlm,
44
+ wait,
45
+ wait_expression,
46
+ )
28
47
  from openc3.script.exceptions import CheckError, StopScriptError
29
48
  from openc3.script_engines.script_engine import ScriptEngine
30
- from openc3.script import wait, wait_expression, ask_string, clear_screen, clear_all_screens, set_tlm, display_screen, \
31
- connect_interface, disconnect_interface, send_raw, start, cmd, get_target_file, tlm, ask_string, set_line_delay, step_mode, run_mode
49
+
50
+ # Pieces of a yyyy/doy-HH:MM:SS timestamp. The tokenizer splits timestamps on '/'
51
+ # and '-', so cstol_tokenizer uses these to stitch the pieces back together.
52
+ YEAR_PATTERN = re.compile(r"^\d{4}$")
53
+ DAY_OF_YEAR_PATTERN = re.compile(r"^\d{1,3}$")
54
+ CLOCK_TIME_PATTERN = re.compile(r"^\d{1,2}:\d{1,2}:\d{1,2}\.?\d*$")
55
+
56
+ SPECIAL_VARIABLE_PREFIX = "$$"
57
+
58
+ # CSTOL %<format> specifiers, mapped to the Python format spec that implements them.
59
+ # %X or %x Output in hexadecimal values
60
+ # %O or %o Output in octal values
61
+ # %B or %b Output in binary values
62
+ # %I or %i Output in decimal values
63
+ # %D or %d Output in decimal values
64
+ # The five above convert the value to an integer prior to applying the format.
65
+ # %F or %f Output in floating point values Default for integer or raw value
66
+ # %E or %e Output in floating point values Default for float or EU value
67
+ INTEGER_FORMATS = {"X": "X", "O": "o", "B": "b", "I": "d", "D": "d"}
68
+ FLOAT_FORMATS = {"F": ".6f", "E": ".6e"}
69
+
70
+
71
+ class SpecialVars:
72
+ """
73
+ Names of the special ($$) variables the engine reads or writes itself. Scripts
74
+ can reference any special variable by name, these are only the ones referenced
75
+ from Python code.
76
+ """
77
+
78
+ OWLT = "$$OWLT"
79
+ ERROR = "$$ERROR"
80
+ CHECK_INTERVAL = "$$CHECK_INTERVAL"
81
+ STEP_INTERVAL = "$$STEP_INTERVAL"
82
+ CLP_STP_INTERVAL = "$$CLP_STP_INTERVAL"
83
+ CLP_STEP_MODE = "$$CLP_STEP_MODE"
84
+ STEP_MODE = "$$STEP_MODE"
85
+ CURRENT_TIME = "$$CURRENT_TIME"
86
+ SC_TIME = "$$SC_TIME"
87
+ LOOP_COUNT = "$$LOOP_COUNT"
88
+
32
89
 
33
90
  class CstolVariables:
34
91
  # NOTE: special_variables is INTENTIONALLY a class-level (shared) dictionary.
@@ -38,13 +95,13 @@ class CstolVariables:
38
95
  # be shared by all instances. Do NOT move this into __init__ or copy it per
39
96
  # instance - that would break the intended global behavior.
40
97
  special_variables = {
41
- "$$OWLT": 0.0,
42
- "$$ERROR": "NO_ERROR",
43
- "$$CHECK_INTERVAL": 1.0,
44
- "$$STEP_INTERVAL": 0.1,
45
- "$$CLP_STP_INTERVAL": 0.1,
46
- "$$CLP_STEP_MODE": "PAUSE",
47
- "$$STEP_MODE": "PAUSE"
98
+ SpecialVars.OWLT: 0.0,
99
+ SpecialVars.ERROR: "NO_ERROR",
100
+ SpecialVars.CHECK_INTERVAL: 1.0,
101
+ SpecialVars.STEP_INTERVAL: 0.1,
102
+ SpecialVars.CLP_STP_INTERVAL: 0.1,
103
+ SpecialVars.CLP_STEP_MODE: "PAUSE",
104
+ SpecialVars.STEP_MODE: "PAUSE",
48
105
  }
49
106
 
50
107
  def __init__(self):
@@ -71,46 +128,53 @@ class CstolVariables:
71
128
  Sets a special variable, which is a variable that starts with '$$'.
72
129
  Special variables are not stored in the local variables dictionary.
73
130
  """
74
- if not name.startswith('$$'):
75
- raise ValueError(f"Special variable names must start with '$$': {name}")
131
+ if not name.startswith(SPECIAL_VARIABLE_PREFIX):
132
+ raise ValueError(
133
+ f"Special variable names must start with '{SPECIAL_VARIABLE_PREFIX}': {name}"
134
+ )
76
135
  name = name.upper()
77
136
  self.special_variables[name] = value
78
137
  match name:
79
- case "$$CLP_STP_INTERVAL":
138
+ case SpecialVars.CLP_STP_INTERVAL:
80
139
  set_line_delay(value)
81
- self.special_variables["$$STEP_INTERVAL"] = value
82
- case "$$STEP_INTERVAL":
83
- self.set_special_variable("$$CLP_STP_INTERVAL", value)
84
- case "$$CLP_STEP_MODE":
140
+ self.special_variables[SpecialVars.STEP_INTERVAL] = value
141
+ case SpecialVars.STEP_INTERVAL:
142
+ self.set_special_variable(SpecialVars.CLP_STP_INTERVAL, value)
143
+ case SpecialVars.CLP_STEP_MODE:
85
144
  mode = str(value).upper()
86
145
  if mode == "GO":
87
146
  run_mode()
88
147
  set_line_delay(0)
89
148
  elif mode == "PAUSE":
90
149
  run_mode()
91
- set_line_delay(self.special_variables["$$STEP_INTERVAL"])
150
+ set_line_delay(self.special_variables[SpecialVars.STEP_INTERVAL])
92
151
  elif mode == "WAIT":
93
152
  step_mode()
94
153
  else:
95
154
  raise ValueError(f"Invalid step mode: {mode}")
96
- self.special_variables["$$STEP_MODE"] = value
97
- case "$$STEP_MODE":
98
- self.set_special_variable("$$CLP_STEP_MODE", value)
155
+ self.special_variables[SpecialVars.STEP_MODE] = value
156
+ case SpecialVars.STEP_MODE:
157
+ self.set_special_variable(SpecialVars.CLP_STEP_MODE, value)
99
158
 
100
159
  def get_special_variable(self, name):
101
160
  """
102
161
  Gets a special variable by name.
103
162
  Returns None if the variable does not exist.
104
163
  """
105
- if not name.startswith('$$'):
106
- raise ValueError(f"Special variable names must start with '$$': {name}")
164
+ if not name.startswith(SPECIAL_VARIABLE_PREFIX):
165
+ raise ValueError(
166
+ f"Special variable names must start with '{SPECIAL_VARIABLE_PREFIX}': {name}"
167
+ )
107
168
  name = name.upper()
108
169
  match name:
109
- case "$$CURRENT_TIME":
110
- return datetime.datetime.now(datetime.timezone.utc).timestamp()
111
- case "$$SC_TIME":
112
- return datetime.datetime.now(datetime.timezone.utc).timestamp() + self.special_variables["$$OWLT"]
113
- case "$$LOOP_COUNT":
170
+ case SpecialVars.CURRENT_TIME:
171
+ return datetime.datetime.now(datetime.UTC).timestamp()
172
+ case SpecialVars.SC_TIME:
173
+ return (
174
+ datetime.datetime.now(datetime.UTC).timestamp()
175
+ + self.special_variables[SpecialVars.OWLT]
176
+ )
177
+ case SpecialVars.LOOP_COUNT:
114
178
  if len(self.loop_stack) > 0:
115
179
  return self.loop_stack[-1][2]
116
180
  else:
@@ -118,91 +182,91 @@ class CstolVariables:
118
182
 
119
183
  return self.special_variables.get(name, None)
120
184
 
121
- class CstolScriptEngine(ScriptEngine):
122
185
 
186
+ class CstolScriptEngine(ScriptEngine):
123
187
  ONE_YEAR_SECONDS = 31536000
124
188
 
125
189
  # Dictionary of known CSTOL tokens - Unknown could be numbers or parts of telemetry names
126
190
  KNOWN_TOKENS = {
127
- 'AND': 'and',
128
- 'OR': 'or',
129
- 'XOR': 'xor',
130
- 'NOT': 'not',
131
- 'FOR': '',
132
- 'UNTIL': '',
133
- 'MOD': '%',
134
- '**': '**',
135
- '*': '*',
136
- '(': '(',
137
- ')': ')',
138
- '<': '<',
139
- '>': '>',
140
- '<=': '<=',
141
- '>=': '>=',
142
- '/=': '!=',
143
- '=': '==',
144
- '+': '+',
145
- '-': '-',
146
- '/': '/',
147
- 'TRUE': 'True',
148
- 'FALSE': 'False',
191
+ "AND": "and",
192
+ "OR": "or",
193
+ "XOR": "xor",
194
+ "NOT": "not",
195
+ "FOR": "",
196
+ "UNTIL": "",
197
+ "MOD": "%",
198
+ "**": "**",
199
+ "*": "*",
200
+ "(": "(",
201
+ ")": ")",
202
+ "<": "<",
203
+ ">": ">",
204
+ "<=": "<=",
205
+ ">=": ">=",
206
+ "/=": "!=",
207
+ "=": "==",
208
+ "+": "+",
209
+ "-": "-",
210
+ "/": "/",
211
+ "TRUE": "True",
212
+ "FALSE": "False",
149
213
  }
150
214
 
151
215
  FUNCTION_TOKENS = {
152
- 'SIN': 'math.sin',
153
- 'COS': 'math.cos',
154
- 'TAN': 'math.tan',
155
- 'ASIN': 'math.asin',
156
- 'ACOS': 'math.acos',
157
- 'ATAN': 'math.atan',
158
- 'SINH': 'math.sinh',
159
- 'COSH': 'math.cosh',
160
- 'TANH': 'math.tanh',
161
- 'EXP': 'math.exp',
162
- 'LOG': 'math.log',
163
- 'LOG2': 'math.log2',
164
- 'LOG10': 'math.log10',
165
- 'SQRT': 'math.sqrt',
166
- 'EVAL': 'eval',
167
- 'GETENV': 'os.getenv',
216
+ "SIN": "math.sin",
217
+ "COS": "math.cos",
218
+ "TAN": "math.tan",
219
+ "ASIN": "math.asin",
220
+ "ACOS": "math.acos",
221
+ "ATAN": "math.atan",
222
+ "SINH": "math.sinh",
223
+ "COSH": "math.cosh",
224
+ "TANH": "math.tanh",
225
+ "EXP": "math.exp",
226
+ "LOG": "math.log",
227
+ "LOG2": "math.log2",
228
+ "LOG10": "math.log10",
229
+ "SQRT": "math.sqrt",
230
+ "EVAL": "eval",
231
+ "GETENV": "os.getenv",
168
232
  }
169
233
 
170
234
  KNOWN_UNITS = [
171
- 'DN',
172
- 'A',
173
- 'C',
174
- 'CM',
175
- 'F',
176
- 'FT',
177
- 'G',
178
- 'GHZ',
179
- 'H',
180
- 'HZ',
181
- 'IN',
182
- 'J',
183
- 'K',
184
- 'KG',
185
- 'KM',
186
- 'KOHM',
187
- 'KV',
188
- 'KW',
189
- 'M',
190
- 'MA',
191
- 'MG',
192
- 'MHZ',
193
- 'MIN',
194
- 'MM',
195
- 'MOHM',
196
- 'MV',
197
- 'MW',
198
- 'OHM',
199
- 'PA',
200
- 'PSI',
201
- 'S',
202
- 'UA',
203
- 'UV',
204
- 'V',
205
- 'W',
235
+ "DN",
236
+ "A",
237
+ "C",
238
+ "CM",
239
+ "F",
240
+ "FT",
241
+ "G",
242
+ "GHZ",
243
+ "H",
244
+ "HZ",
245
+ "IN",
246
+ "J",
247
+ "K",
248
+ "KG",
249
+ "KM",
250
+ "KOHM",
251
+ "KV",
252
+ "KW",
253
+ "M",
254
+ "MA",
255
+ "MG",
256
+ "MHZ",
257
+ "MIN",
258
+ "MM",
259
+ "MOHM",
260
+ "MV",
261
+ "MW",
262
+ "OHM",
263
+ "PA",
264
+ "PSI",
265
+ "S",
266
+ "UA",
267
+ "UV",
268
+ "V",
269
+ "W",
206
270
  ]
207
271
 
208
272
  def __init__(self, running_script):
@@ -210,7 +274,52 @@ class CstolScriptEngine(ScriptEngine):
210
274
  self.variables = CstolVariables()
211
275
  self.saved_tokens = None
212
276
 
213
- def cstol_tokenizer(self, s, special_chars='()><+-*/=;,'):
277
+ def timestamp_token_count(self, tokens, i):
278
+ """
279
+ Returns how many tokens starting at index i form a single timestamp, or None if
280
+ they do not. The tokenizer splits timestamps on '/' and '-', so
281
+ yyyy/doy-HH:MM:SS arrives as ["yyyy", "/", "doy", "-", "HH:MM:SS"].
282
+
283
+ Each form requires at least one token to follow the timestamp, matching the
284
+ original bounds checks.
285
+ """
286
+ remaining = len(tokens) - i
287
+
288
+ # yyyy/doy-HH:MM:SS
289
+ if (
290
+ remaining >= 5
291
+ and YEAR_PATTERN.match(tokens[i])
292
+ and tokens[i + 1] == "/"
293
+ and DAY_OF_YEAR_PATTERN.match(tokens[i + 2])
294
+ and tokens[i + 3] == "-"
295
+ and CLOCK_TIME_PATTERN.match(tokens[i + 4])
296
+ ):
297
+ return 5
298
+
299
+ # /doy-HH:MM:SS or yyyy/-HH:MM:SS
300
+ if (
301
+ remaining >= 4
302
+ and CLOCK_TIME_PATTERN.match(tokens[i + 3])
303
+ and (
304
+ (tokens[i] == "/" and DAY_OF_YEAR_PATTERN.match(tokens[i + 1]))
305
+ or (YEAR_PATTERN.match(tokens[i]) and tokens[i + 1] == "/")
306
+ )
307
+ and tokens[i + 2] == "-"
308
+ ):
309
+ return 4
310
+
311
+ # /-HH:MM:SS
312
+ if (
313
+ remaining >= 3
314
+ and tokens[i] == "/"
315
+ and tokens[i + 1] == "-"
316
+ and CLOCK_TIME_PATTERN.match(tokens[i + 2])
317
+ ):
318
+ return 3
319
+
320
+ return None
321
+
322
+ def cstol_tokenizer(self, s, special_chars="()><+-*/=;,"):
214
323
  tokens = self.tokenizer(s, special_chars)
215
324
 
216
325
  # Reconstruct full timestamps of the format yyyy/doy-HH:MM:SS into single tokens
@@ -218,65 +327,28 @@ class CstolScriptEngine(ScriptEngine):
218
327
  reconstructed_tokens = []
219
328
  i = 0
220
329
  while i < len(tokens):
221
- # Check if we have a potential timestamp pattern: yyyy/doy-HH:MM:SS
222
- if (i + 4 < len(tokens) and
223
- re.match(r'^\d{4}$', tokens[i]) and
224
- tokens[i + 1] == '/' and
225
- re.match(r'^\d{1,3}$', tokens[i + 2]) and
226
- tokens[i + 3] == '-' and
227
- re.match(r'^\d{1,2}:\d{1,2}:\d{1,2}\.?\d*$', tokens[i + 4])):
228
- # Reconstruct the full timestamp
229
- timestamp = tokens[i] + tokens[i + 1] + tokens[i + 2] + tokens[i + 3] + tokens[i + 4]
230
- reconstructed_tokens.append(timestamp)
231
- i += 5 # Skip the next 4 tokens since we combined them
232
-
233
- # Check if we have a potential timestamp pattern: /doy-HH:MM:SS
234
- elif (i + 3 < len(tokens) and
235
- tokens[i] == '/' and
236
- re.match(r'^\d{1,3}$', tokens[i + 1]) and
237
- tokens[i + 2] == '-' and
238
- re.match(r'^\d{1,2}:\d{1,2}:\d{1,2}\.?\d*$', tokens[i + 3])):
239
- # Reconstruct the full timestamp
240
- timestamp = tokens[i] + tokens[i + 1] + tokens[i + 2] + tokens[i + 3]
241
- reconstructed_tokens.append(timestamp)
242
- i += 4 # Skip the next 3 tokens since we combined them
243
-
244
- # Check if we have a potential timestamp pattern: yyyy/-HH:MM:SS
245
- elif (i + 3 < len(tokens) and
246
- re.match(r'^\d{4}$', tokens[i]) and
247
- tokens[i + 1] == '/' and
248
- tokens[i + 2] == '-' and
249
- re.match(r'^\d{1,2}:\d{1,2}:\d{1,2}\.?\d*$', tokens[i + 3])):
250
- # Reconstruct the full timestamp
251
- timestamp = tokens[i] + tokens[i + 1] + tokens[i + 2] + tokens[i + 3]
252
- reconstructed_tokens.append(timestamp)
253
- i += 4 # Skip the next 3 tokens since we combined them
254
-
255
- # Check if we have a potential timestamp pattern: yyyy/-HH:MM:SS
256
- elif (i + 2 < len(tokens) and
257
- tokens[i] == '/' and
258
- tokens[i + 1] == '-' and
259
- re.match(r'^\d{1,2}:\d{1,2}:\d{1,2}\.?\d*$', tokens[i + 2])):
260
- # Reconstruct the full timestamp
261
- timestamp = tokens[i] + tokens[i + 1] + tokens[i + 2]
262
- reconstructed_tokens.append(timestamp)
263
- i += 3 # Skip the next 2 tokens since we combined them
330
+ timestamp_length = self.timestamp_token_count(tokens, i)
331
+ if timestamp_length:
332
+ reconstructed_tokens.append("".join(tokens[i : i + timestamp_length]))
333
+ i += timestamp_length
334
+ continue
264
335
 
265
- else:
266
- # Recombine multi-part operator tokens
267
- token = tokens[i]
268
- if token in self.KNOWN_TOKENS and i + 1 < len(tokens):
269
- next_token = tokens[i + 1]
270
- if ((token == '*' and next_token == '*') or
271
- (token == '<' and next_token == '=') or
272
- (token == '>' and next_token == '=') or
273
- (token == '/' and next_token == '=')):
274
- reconstructed_tokens.append(token + next_token)
275
- i += 2 # Skip the next token
276
- continue
277
-
278
- reconstructed_tokens.append(token)
279
- i += 1
336
+ # Recombine multi-part operator tokens
337
+ token = tokens[i]
338
+ if token in self.KNOWN_TOKENS and i + 1 < len(tokens):
339
+ next_token = tokens[i + 1]
340
+ if (
341
+ (token == "*" and next_token == "*")
342
+ or (token == "<" and next_token == "=")
343
+ or (token == ">" and next_token == "=")
344
+ or (token == "/" and next_token == "=")
345
+ ):
346
+ reconstructed_tokens.append(token + next_token)
347
+ i += 2 # Skip the next token
348
+ continue
349
+
350
+ reconstructed_tokens.append(token)
351
+ i += 1
280
352
 
281
353
  return reconstructed_tokens
282
354
 
@@ -284,7 +356,8 @@ class CstolScriptEngine(ScriptEngine):
284
356
  """
285
357
  Builds a Python expression from the provided tokens.
286
358
 
287
- Needs to handle all the CSTOL operators and syntax, converting them into valid Python syntax.
359
+ Needs to handle all the CSTOL operators and syntax, converting them into
360
+ valid Python syntax.
288
361
 
289
362
  Args:
290
363
  expression_tokens: List of tokens that form a valid Python expression
@@ -303,7 +376,7 @@ class CstolScriptEngine(ScriptEngine):
303
376
  previous_number = False
304
377
 
305
378
  # Handle special variables
306
- if token.startswith('$$'):
379
+ if token.startswith("$$"):
307
380
  # Get the actual value of special variable
308
381
  value = self.variables.get_special_variable(token)
309
382
  if isinstance(value, str):
@@ -313,7 +386,7 @@ class CstolScriptEngine(ScriptEngine):
313
386
  continue
314
387
 
315
388
  # Handle local variables
316
- if token.startswith('$'):
389
+ if token.startswith("$"):
317
390
  # Get the actual value of local variable
318
391
  value = self.variables.get_local_variable(token)
319
392
  if value is None:
@@ -333,7 +406,7 @@ class CstolScriptEngine(ScriptEngine):
333
406
  # Handle CSTOL functions
334
407
  if token.upper() in self.FUNCTION_TOKENS:
335
408
  # Function tokens must be followed by ( or they will be considered just strings
336
- if len(expression_tokens) > index + 1 and expression_tokens[index + 1] == '(':
409
+ if len(expression_tokens) > index + 1 and expression_tokens[index + 1] == "(":
337
410
  token = self.FUNCTION_TOKENS[token.upper()]
338
411
  final_tokens.append(token)
339
412
  else:
@@ -349,16 +422,18 @@ class CstolScriptEngine(ScriptEngine):
349
422
  # Handle radix notation integers
350
423
  # Allow hex digits (A-F) for all bases so X#FF and H#DEADBEEF parse;
351
424
  # int() with the proper base validates the digits for each radix.
352
- matches = re.match(r'^([BODXHbodxh])#([0-9A-Fa-f]+)$', token)
425
+ matches = re.match(r"^([BODXHbodxh])#([0-9A-Fa-f]+)$", token)
353
426
  if matches is not None:
354
427
  radix_char = matches.group(1).upper()
355
428
  digits = matches.group(2)
356
429
  match radix_char:
357
- case 'H':
430
+ case "H":
358
431
  # Hex byte buffer (raw bytes rather than an integer)
359
- final_tokens.append(str(int(digits, 16).to_bytes((len(digits) + 1) // 2, 'big')))
432
+ final_tokens.append(
433
+ str(int(digits, 16).to_bytes((len(digits) + 1) // 2, "big"))
434
+ )
360
435
  case _:
361
- base = {'B': 2, 'O': 8, 'D': 10, 'X': 16}[radix_char]
436
+ base = {"B": 2, "O": 8, "D": 10, "X": 16}[radix_char]
362
437
  final_tokens.append(str(int(digits, base)))
363
438
  previous_number = True
364
439
  continue
@@ -370,7 +445,7 @@ class CstolScriptEngine(ScriptEngine):
370
445
  # - Digits, optional decimal point and more digits
371
446
  # - Optional scientific notation (e/E followed by optional sign and digits)
372
447
  # - Ignores any alphabetic characters that follow
373
- matches = re.match(r'^([+-]?\d*\.?\d+(?:[eE][+-]?\d+)?)', token)
448
+ matches = re.match(r"^([+-]?\d*\.?\d+(?:[eE][+-]?\d+)?)", token)
374
449
  if matches is not None:
375
450
  final_tokens.append(str(matches.group(1)))
376
451
  previous_number = True
@@ -394,22 +469,30 @@ class CstolScriptEngine(ScriptEngine):
394
469
  token = final_tokens[i]
395
470
 
396
471
  # Check for "RAW" pattern first: "RAW", "TARGET_NAME", "ITEM_NAME"
397
- if (token.upper() == '"RAW"' and i + 2 < len(final_tokens) and
398
- final_tokens[i + 1].startswith('"') and final_tokens[i + 1].endswith('"') and
399
- final_tokens[i + 2].startswith('"') and final_tokens[i + 2].endswith('"')):
400
-
472
+ if (
473
+ token.upper() == '"RAW"'
474
+ and i + 2 < len(final_tokens)
475
+ and final_tokens[i + 1].startswith('"')
476
+ and final_tokens[i + 1].endswith('"')
477
+ and final_tokens[i + 2].startswith('"')
478
+ and final_tokens[i + 2].endswith('"')
479
+ ):
401
480
  target_name = final_tokens[i + 1][1:-1] # Remove quotes
402
- item_name = final_tokens[i + 2][1:-1] # Remove quotes
481
+ item_name = final_tokens[i + 2][1:-1] # Remove quotes
403
482
  tlm_call = f'tlm("{target_name}", "LATEST", "{item_name}", type="RAW")'
404
483
  processed_tokens.append(tlm_call)
405
484
  i += 3 # Skip the next 2 tokens
406
485
 
407
486
  # Check for regular pattern: "TARGET_NAME", "ITEM_NAME"
408
- elif (token.startswith('"') and token.endswith('"') and i + 1 < len(final_tokens) and
409
- final_tokens[i + 1].startswith('"') and final_tokens[i + 1].endswith('"')):
410
-
411
- target_name = token[1:-1] # Remove quotes
412
- item_name = final_tokens[i + 1][1:-1] # Remove quotes
487
+ elif (
488
+ token.startswith('"')
489
+ and token.endswith('"')
490
+ and i + 1 < len(final_tokens)
491
+ and final_tokens[i + 1].startswith('"')
492
+ and final_tokens[i + 1].endswith('"')
493
+ ):
494
+ target_name = token[1:-1] # Remove quotes
495
+ item_name = final_tokens[i + 1][1:-1] # Remove quotes
413
496
  tlm_call = f'tlm("{target_name}", "LATEST", "{item_name}")'
414
497
  processed_tokens.append(tlm_call)
415
498
  i += 2 # Skip the next token
@@ -420,15 +503,15 @@ class CstolScriptEngine(ScriptEngine):
420
503
  i += 1
421
504
 
422
505
  # Join the processed tokens into a single string
423
- return ' '.join(processed_tokens)
506
+ return " ".join(processed_tokens)
424
507
 
425
508
  # Combined regex pattern to match both formats
426
- TIMESTAMP_PATTERN = r'(?:(\d{4})?/(\d{1,3})?-)?(\d{0,2}):(\d{0,2}):(\d{1,2}\.?\d*)'
509
+ TIMESTAMP_PATTERN = r"(?:(\d{4})?/(\d{1,3})?-)?(\d{0,2}):(\d{0,2}):(\d{1,2}\.?\d*)"
427
510
 
428
511
  # Will convert a CSTOL Clock Time or Delta Time into floating point seconds
429
- def parse_timestamp(self, timestamp, now = None):
512
+ def parse_timestamp(self, timestamp, now=None):
430
513
  if now is None:
431
- now = datetime.datetime.now(datetime.timezone.utc)
514
+ now = datetime.datetime.now(datetime.UTC)
432
515
 
433
516
  matches = re.match(self.TIMESTAMP_PATTERN, timestamp)
434
517
  if matches:
@@ -437,61 +520,54 @@ class CstolScriptEngine(ScriptEngine):
437
520
  hour = 0
438
521
  if len(minute) == 0:
439
522
  minute = 0
440
- result = {
441
- 'hour': int(hour),
442
- 'minute': int(minute),
443
- 'second': float(second)
444
- }
523
+ result = {"hour": int(hour), "minute": int(minute), "second": float(second)}
445
524
 
446
525
  # Determine if / was present (meaning a clock time rather than delta time)
447
- if '/' in timestamp:
448
- result['clock_time'] = True
526
+ if "/" in timestamp:
527
+ result["clock_time"] = True
449
528
  else:
450
- result['clock_time'] = False
529
+ result["clock_time"] = False
451
530
 
452
531
  # Add year and day_of_year if they exist
453
532
  if year is not None:
454
- result['year'] = int(year)
455
- elif result['clock_time']:
456
- result['year'] = now.year
533
+ result["year"] = int(year)
534
+ elif result["clock_time"]:
535
+ result["year"] = now.year
457
536
 
458
537
  if day_of_year is not None:
459
- result['day_of_year'] = int(day_of_year)
460
- elif result['clock_time']:
461
- result['day_of_year'] = now.timetuple().tm_yday
538
+ result["day_of_year"] = int(day_of_year)
539
+ elif result["clock_time"]:
540
+ result["day_of_year"] = now.timetuple().tm_yday
462
541
 
463
542
  # Extract seconds and microseconds from fractional seconds
464
- total_seconds = result['second']
543
+ total_seconds = result["second"]
465
544
  seconds = int(total_seconds)
466
545
  # Use round to handle floating point precision issues
467
546
  microseconds = round((total_seconds - seconds) * 1000000)
468
547
 
469
- if 'year' in result and 'day_of_year' in result:
548
+ if "year" in result and "day_of_year" in result:
470
549
  # Create datetime from year and day of year
471
550
  base_date = datetime.datetime(
472
- year=result['year'],
473
- month=1,
474
- day=1,
475
- tzinfo=datetime.timezone.utc
476
- ) + datetime.timedelta(days=result['day_of_year'] - 1)
551
+ year=result["year"], month=1, day=1, tzinfo=datetime.UTC
552
+ ) + datetime.timedelta(days=result["day_of_year"] - 1)
477
553
 
478
554
  return datetime.datetime(
479
555
  year=base_date.year,
480
556
  month=base_date.month,
481
557
  day=base_date.day,
482
- hour=result['hour'],
483
- minute=result['minute'],
558
+ hour=result["hour"],
559
+ minute=result["minute"],
484
560
  second=seconds,
485
561
  microsecond=microseconds,
486
- tzinfo=datetime.timezone.utc
562
+ tzinfo=datetime.UTC,
487
563
  ).timestamp()
488
564
  else:
489
565
  # Delta Time
490
566
  return datetime.timedelta(
491
- hours=result['hour'],
492
- minutes=result['minute'],
567
+ hours=result["hour"],
568
+ minutes=result["minute"],
493
569
  seconds=seconds,
494
- microseconds=microseconds
570
+ microseconds=microseconds,
495
571
  ).total_seconds()
496
572
 
497
573
  return None
@@ -514,6 +590,29 @@ class CstolScriptEngine(ScriptEngine):
514
590
  expressions.append(expression)
515
591
  return expressions
516
592
 
593
+ def _split_wait_timeout(self, tokens):
594
+ """Split WAIT body tokens into (condition_tokens, timeout_tokens | None).
595
+
596
+ Scans from the end of the token list looking for the last ``OR``
597
+ that is immediately followed by ``FOR`` or ``UNTIL``. That ``OR``
598
+ marks the boundary between the boolean condition and the timeout
599
+ clause.
600
+
601
+ Returns
602
+ -------
603
+ (condition_tokens, timeout_tokens)
604
+ *timeout_tokens* includes the ``FOR``/``UNTIL`` keyword and the
605
+ value that follows. If no ``OR FOR`` / ``OR UNTIL`` sequence is
606
+ found the full token list is returned as the condition and
607
+ *timeout_tokens* is ``None``.
608
+ """
609
+ # Walk backwards so we find the last OR FOR / OR UNTIL (before the timeout).
610
+ # Earlier OR tokens are boolean operators inside the condition.
611
+ for i in range(len(tokens) - 1, 0, -1):
612
+ if tokens[i - 1].upper() == "OR" and tokens[i].upper() in ("FOR", "UNTIL"):
613
+ return tokens[: i - 1], tokens[i:]
614
+ return tokens, None
615
+
517
616
  def split_vs_tokens_on_colon(self, tokens):
518
617
  """
519
618
  Split tokens on colons for VS clause processing, but preserve timestamps.
@@ -523,12 +622,12 @@ class CstolScriptEngine(ScriptEngine):
523
622
  """
524
623
  result = []
525
624
  for token in tokens:
526
- if ':' in token and not re.fullmatch(self.TIMESTAMP_PATTERN, token):
625
+ if ":" in token and not re.fullmatch(self.TIMESTAMP_PATTERN, token):
527
626
  # Split non-timestamp tokens containing colons
528
- parts = token.split(':')
627
+ parts = token.split(":")
529
628
  for i, part in enumerate(parts):
530
629
  if i > 0:
531
- result.append(':')
630
+ result.append(":")
532
631
  if part: # Only add non-empty parts
533
632
  result.append(part)
534
633
  else:
@@ -545,7 +644,7 @@ class CstolScriptEngine(ScriptEngine):
545
644
  result = None
546
645
  try:
547
646
  # Evaluate the expression and return the result
548
- result = eval(python_expression, {'math': math, 'os': os, 'tlm': tlm})
647
+ result = eval(python_expression, {"math": math, "os": os, "tlm": tlm})
549
648
  except Exception as e:
550
649
  raise ValueError(f"Error evaluating expression '{python_expression}': {e}")
551
650
  return result
@@ -571,7 +670,9 @@ class CstolScriptEngine(ScriptEngine):
571
670
 
572
671
  variable = tokens[1]
573
672
  question = tokens[2]
574
- if (question.startswith('"') and question.endswith('"')) or (question.startswith("'") and question.endswith("'")):
673
+ if (question.startswith('"') and question.endswith('"')) or (
674
+ question.startswith("'") and question.endswith("'")
675
+ ):
575
676
  # Remove quotes
576
677
  question = question[1:-1]
577
678
  answer = ask_string(question)
@@ -579,7 +680,7 @@ class CstolScriptEngine(ScriptEngine):
579
680
  if len(answer) >= 2 and answer[0] == '"' and answer[-1] == '"':
580
681
  # Remove quotes and don't uppercase and don't eval
581
682
  answer = answer[1:-1]
582
- elif answer.strip() == '':
683
+ elif answer.strip() == "":
583
684
  # Empty answer - store as-is without tokenizing/evaluating
584
685
  pass
585
686
  else:
@@ -594,28 +695,43 @@ class CstolScriptEngine(ScriptEngine):
594
695
  answer = answer.upper()
595
696
  self.variables.set_local_variable(variable, answer)
596
697
 
698
+ def parse_format(self, expr, keyword, line_no):
699
+ """
700
+ Splits a leading %<format> token off of expr. Returns the format specifier and
701
+ the remaining tokens, with a specifier of None when expr has no format token.
702
+ """
703
+ if expr[0][0] != "%":
704
+ return None, expr
705
+
706
+ format_spec = expr[0][1:].upper()
707
+ if format_spec not in INTEGER_FORMATS and format_spec not in FLOAT_FORMATS:
708
+ raise ValueError(
709
+ f"Invalid format %'{format_spec}' in {keyword} command at line {line_no}"
710
+ )
711
+ if len(expr) < 2:
712
+ raise ValueError(
713
+ f"Missing value for format %'{format_spec}' in {keyword} command at line {line_no}"
714
+ )
715
+ return format_spec, expr[1:]
716
+
717
+ def apply_format(self, format_spec, value):
718
+ """
719
+ Renders value using a CSTOL %<format> specifier, or str(value) when there is no
720
+ specifier. Integer formats convert the value to an integer first, float formats
721
+ convert it to a float.
722
+ """
723
+ if format_spec is None:
724
+ return str(value)
725
+ if format_spec in INTEGER_FORMATS:
726
+ return format(int(value), INTEGER_FORMATS[format_spec])
727
+ return format(float(value), FLOAT_FORMATS[format_spec])
728
+
597
729
  def handle_check(self, tokens, line_no):
598
730
  expressions = self.extract_expressions(tokens[1:], ",")
599
731
  for expr in expressions:
600
732
  if len(expr) == 0:
601
733
  raise ValueError(f"Empty expression in CHECK command at line {line_no}")
602
- format = None
603
- if expr[0][0] == '%':
604
- # Format string
605
- # %X or %x Output in hexadecimal values Value is converted to an integer prior to applying the format
606
- # %O or %o Output in octal values Value is converted to an integer prior to applying the format
607
- # %B or %b Output in binary values Value is converted to an integer prior to applying the format
608
- # %I or %i Output in decimal values Value is converted to an integer prior to applying the format
609
- # %D or %d Output in decimal values Value is converted to an integer prior to applying the format
610
- # %F or %f Output in floating point values Default for integer or raw value
611
- # %E or %e Output in floating point values Default for float or EU value
612
- format = expr[0][1:].upper()
613
- if format not in ['X', 'O', 'B', 'I', 'D', 'F', 'E']:
614
- raise ValueError(f"Invalid format %'{format}' in CHECK command at line {line_no}")
615
- if len(expr) < 2:
616
- raise ValueError(f"Missing value for format %'{format}' in CHECK command at line {line_no}")
617
- # Remove format from the expression
618
- expr = expr[1:]
734
+ format_spec, expr = self.parse_format(expr, "CHECK", line_no)
619
735
 
620
736
  # Check for VS
621
737
  success = True
@@ -636,7 +752,9 @@ class CstolScriptEngine(ScriptEngine):
636
752
  range_start = self.evaluate_expression(colon_expressions[0])
637
753
  range_end = self.evaluate_expression(colon_expressions[1])
638
754
  if range_start > range_end:
639
- raise ValueError(f"Invalid VS range {range_start}:{range_end} at line {line_no}")
755
+ raise ValueError(
756
+ f"Invalid VS range {range_start}:{range_end} at line {line_no}"
757
+ )
640
758
  if (value < range_start) or (value > range_end):
641
759
  success = False
642
760
  fail_message = f"not in range {range_start}:{range_end}"
@@ -647,31 +765,11 @@ class CstolScriptEngine(ScriptEngine):
647
765
  success = False
648
766
  fail_message = f"not equal to {vs_value}"
649
767
  else:
650
- # No VS - Just a print check
768
+ # No VS - Just a print check
651
769
  source = " ".join(expr)
652
770
  value = self.evaluate_expression(expr)
653
771
 
654
- formatted_value = None
655
- if format is None:
656
- formatted_value = str(value)
657
- elif format in ['X', 'O', 'B', 'I', 'D']:
658
- # Convert to integer
659
- value = int(value)
660
- if format == 'X':
661
- formatted_value = (f"{value:X}")
662
- elif format == 'O':
663
- formatted_value = (f"{value:o}")
664
- elif format == 'B':
665
- formatted_value = (f"{value:b}")
666
- elif format in ['I', 'D']:
667
- formatted_value = str(value)
668
- elif format in ['F', 'E']:
669
- # Convert to float
670
- value = float(value)
671
- if format == 'F':
672
- formatted_value = f"{value:.6f}"
673
- elif format == 'E':
674
- formatted_value = f"{value:.6e}"
772
+ formatted_value = self.apply_format(format_spec, value)
675
773
 
676
774
  if success:
677
775
  print(f"CHECK SUCCESS: {source} = {formatted_value}")
@@ -681,37 +779,73 @@ class CstolScriptEngine(ScriptEngine):
681
779
  raise CheckError(message)
682
780
 
683
781
  def handle_clear(self, tokens, line_no):
684
- """ Only handles displays """
685
- if tokens[1].upper() == 'ALL':
782
+ """Only handles displays"""
783
+ if tokens[1].upper() == "ALL":
686
784
  clear_all_screens()
687
785
  else:
688
786
  screen_name = tokens[1]
689
- if (screen_name.startswith('"') and screen_name.endswith('"')) or (screen_name.startswith("'") and screen_name.endswith("'")):
787
+ if (screen_name.startswith('"') and screen_name.endswith('"')) or (
788
+ screen_name.startswith("'") and screen_name.endswith("'")
789
+ ):
690
790
  # Remove quotes
691
791
  screen_name = screen_name[1:-1]
692
792
  clear_screen(*screen_name.split())
693
793
 
694
794
  def handle_cmd(self, tokens, line_no):
695
- if tokens[0].upper() == 'NOW':
795
+ if tokens[0].upper() == "NOW":
696
796
  # Drop NOW
697
797
  tokens = tokens[1:]
698
798
 
699
799
  verb = tokens[0].upper()
700
800
  tokens = tokens[1:]
701
801
  match verb:
702
- case "ACTIVATE" | "ARM" | "BOOT" | "CHANGE" | "CLOSE" | "DISABLE" | "DISARM" | "DRIVE" | \
703
- "DUMP" | "ENABLE" | "FIRE" | "FLYBACK" | "FORCE" | "GET" | "HALT" | "HOLD" | "IGNORE" | "INITIATE" | \
704
- "MOVE" | "NOW" | "OPEN" | "PASS" | "PERFORM" | "RESET" | "SELECT" | "SET" | "SLEW" | "STEP" | "TEST" | \
705
- "TOGGLE" | "TURN" | "USE":
802
+ case (
803
+ "ACTIVATE"
804
+ | "ARM"
805
+ | "BOOT"
806
+ | "CHANGE"
807
+ | "CLOSE"
808
+ | "DISABLE"
809
+ | "DISARM"
810
+ | "DRIVE"
811
+ | "DUMP"
812
+ | "ENABLE"
813
+ | "FIRE"
814
+ | "FLYBACK"
815
+ | "FORCE"
816
+ | "GET"
817
+ | "HALT"
818
+ | "HOLD"
819
+ | "IGNORE"
820
+ | "INITIATE"
821
+ | "MOVE"
822
+ | "NOW"
823
+ | "OPEN"
824
+ | "PASS"
825
+ | "PERFORM"
826
+ | "RESET"
827
+ | "SELECT"
828
+ | "SET"
829
+ | "SLEW"
830
+ | "STEP"
831
+ | "TEST"
832
+ | "TOGGLE"
833
+ | "TURN"
834
+ | "USE"
835
+ ):
706
836
  # Handle all the weird verbs
707
837
  if verb == "TURN" or verb == "FORCE":
708
838
  # Expect ON or OFF to follow
709
- if tokens[0].upper() == 'ON' or tokens[0].upper() == 'OFF':
839
+ if tokens[0].upper() == "ON" or tokens[0].upper() == "OFF":
710
840
  verb = verb + tokens[0].upper()
711
841
  tokens = tokens[1:]
712
842
  else:
713
- raise ValueError(f"TURN and FORCE must be followed by ON or OFF at line {line_no}")
843
+ raise ValueError(
844
+ f"TURN and FORCE must be followed by ON or OFF at line {line_no}"
845
+ )
714
846
  case "CMD":
847
+ # CMD is the explicit form of a command, so there is no implied
848
+ # verb to fold into the parameters below
715
849
  pass
716
850
 
717
851
  # Now we need to discover any TO, BY, FROM, WITH clauses
@@ -773,24 +907,29 @@ class CstolScriptEngine(ScriptEngine):
773
907
  cmd(target_name, cmd_name, args)
774
908
 
775
909
  def handle_declare(self, tokens, line_no):
776
- """ We ignore ranges and allowed value lists """
910
+ """We ignore ranges and allowed value lists"""
777
911
  # DECLARE mode variable-name = default-value [range value-list]
778
912
  mode = tokens[1].upper()
779
- if mode not in ['INPUT', 'VARIABLE', 'CONSTANT']:
913
+ if mode not in ["INPUT", "VARIABLE", "CONSTANT"]:
780
914
  raise ValueError(f"Invalid mode '{mode}' in DECLARE command at line {line_no}")
781
915
  variable_name = tokens[2]
782
- if not variable_name.startswith('$'):
783
- raise ValueError(f"Variable name must start with '$' in DECLARE command at line {line_no}")
916
+ if not variable_name.startswith("$"):
917
+ raise ValueError(
918
+ f"Variable name must start with '$' in DECLARE command at line {line_no}"
919
+ )
784
920
  equals = tokens[3]
785
- if equals != '=':
786
- raise ValueError(f"Expected '=' after variable name in DECLARE command at line {line_no}")
787
- #default_value = self.token_to_value(tokens[4])
921
+ if equals != "=":
922
+ raise ValueError(
923
+ f"Expected '=' after variable name in DECLARE command at line {line_no}"
924
+ )
788
925
  default_value = self.evaluate_tokens([tokens[4]])[0]
789
926
  self.variables.set_local_variable(variable_name, default_value)
790
927
 
791
928
  def handle_display(self, tokens, line_no):
792
929
  screen_name = tokens[1]
793
- if (screen_name.startswith('"') and screen_name.endswith('"')) or (screen_name.startswith("'") and screen_name.endswith("'")):
930
+ if (screen_name.startswith('"') and screen_name.endswith('"')) or (
931
+ screen_name.startswith("'") and screen_name.endswith("'")
932
+ ):
794
933
  # Remove quotes
795
934
  screen_name = screen_name[1:-1]
796
935
  display_screen(*screen_name.split())
@@ -799,32 +938,32 @@ class CstolScriptEngine(ScriptEngine):
799
938
  goto_endif = False
800
939
  if len(tokens) > 1:
801
940
  # ELSE IF is tricky - We can hit these after a successful IF, or an unsuccessful IF
802
- # The if_stack keeps track if an earlier if was successful and its value will determine if we
803
- # execute the ELSEIF or just goto the next ENDIF
941
+ # The if_stack keeps track if an earlier if was successful and its
942
+ # value will determine if we execute the ELSEIF or just goto the next ENDIF
804
943
  current_if = False
805
944
  if len(self.variables.if_stack) > 0:
806
945
  current_if = self.variables.if_stack[-1]
807
- if tokens[0].upper() == 'ELSEIF':
946
+ if tokens[0].upper() == "ELSEIF":
808
947
  if current_if:
809
948
  goto_endif = True
810
949
  else:
811
950
  return self.handle_if(tokens, lines, line_no)
812
- elif (tokens[0].upper() == 'ELSE' and tokens[1].upper() == 'IF'):
951
+ elif tokens[0].upper() == "ELSE" and tokens[1].upper() == "IF":
813
952
  if current_if:
814
953
  goto_endif = True
815
954
  else:
816
955
  return self.handle_if(tokens[1:], lines, line_no)
817
956
 
818
- if goto_endif or (len(tokens) == 1 and tokens[0].upper() == 'ELSE'):
957
+ if goto_endif or (len(tokens) == 1 and tokens[0].upper() == "ELSE"):
819
958
  # The only way we ever hit an ELSE is if we were in a successful block beforehand
820
959
  # Therefore goto the ENDIF
821
960
  self.variables.if_stack[-1] = True
822
961
  depth = 1
823
962
  for i in range(line_no, len(lines)):
824
963
  next_line = lines[i].strip().upper()
825
- if next_line.startswith('IF '):
964
+ if next_line.startswith("IF "):
826
965
  depth += 1
827
- elif next_line.startswith('ENDIF') or next_line.startswith('END IF'):
966
+ elif next_line.startswith(("ENDIF", "END IF")):
828
967
  depth -= 1
829
968
  if depth == 0:
830
969
  return i + 1
@@ -833,29 +972,43 @@ class CstolScriptEngine(ScriptEngine):
833
972
  raise ValueError(f"handle_else called with unexpected tokens at line {line_no}")
834
973
 
835
974
  def handle_end(self, tokens, line_no):
836
- if len(tokens) == 1 and tokens[0].upper() == 'END':
837
- raise ValueError(f"Unexpected END command at line {line_no}, expected ENDIF, ENDLOOP, ENDMACRO, or ENDPROC")
838
- elif (len(tokens) == 1 and tokens[0].upper() == 'ENDIF') or (tokens[0].upper() == 'END' and tokens[1].upper() == 'IF'):
975
+ if len(tokens) == 1 and tokens[0].upper() == "END":
976
+ raise ValueError(
977
+ f"Unexpected END command at line {line_no}, "
978
+ "expected ENDIF, ENDLOOP, ENDMACRO, or ENDPROC"
979
+ )
980
+ elif (len(tokens) == 1 and tokens[0].upper() == "ENDIF") or (
981
+ tokens[0].upper() == "END" and tokens[1].upper() == "IF"
982
+ ):
839
983
  # END IF pops the IF stack
840
984
  self.variables.if_stack.pop()
841
- pass
842
- elif (len(tokens) == 1 and tokens[0].upper() == 'ENDLOOP') or (tokens[0].upper() == 'END' and tokens[1].upper() == 'LOOP'):
843
- if len(self.variables.loop_stack) > 0:
844
- loop_info = self.variables.loop_stack[-1]
845
- loop_info[2] += 1
846
- if loop_info[1] is not None:
847
- # Counted loop, decrement the count
848
- loop_info[1] -= 1
849
- if loop_info[1] > 0:
850
- return loop_info[0]
851
- else:
852
- self.variables.loop_stack.pop()
853
- return line_no + 1 # Continue to the next line
854
- else:
855
- # Infinite loop, just continue to the start of the loop
856
- return loop_info[0]
985
+ elif (len(tokens) == 1 and tokens[0].upper() == "ENDLOOP") or (
986
+ tokens[0].upper() == "END" and tokens[1].upper() == "LOOP"
987
+ ):
988
+ return self.handle_end_loop(line_no)
857
989
  return line_no + 1
858
990
 
991
+ def handle_end_loop(self, line_no):
992
+ """
993
+ Advances the innermost loop when its ENDLOOP is reached. Returns the top of the
994
+ loop while iterations remain, otherwise the line after the ENDLOOP.
995
+ """
996
+ if len(self.variables.loop_stack) == 0:
997
+ return line_no + 1
998
+
999
+ loop_info = self.variables.loop_stack[-1]
1000
+ loop_info[2] += 1
1001
+ if loop_info[1] is None:
1002
+ # Infinite loop, just continue to the start of the loop
1003
+ return loop_info[0]
1004
+
1005
+ # Counted loop, decrement the count
1006
+ loop_info[1] -= 1
1007
+ if loop_info[1] > 0:
1008
+ return loop_info[0]
1009
+ self.variables.loop_stack.pop()
1010
+ return line_no + 1 # Continue to the next line
1011
+
859
1012
  def handle_escape(self, _tokens, lines, line_no):
860
1013
  # Find the matching ENDLOOP
861
1014
  depth = 1
@@ -865,9 +1018,11 @@ class CstolScriptEngine(ScriptEngine):
865
1018
  words = lines[i].strip().upper().split()
866
1019
  if not words:
867
1020
  continue
868
- if words[0] == 'LOOP':
1021
+ if words[0] == "LOOP":
869
1022
  depth += 1
870
- elif words[0] == 'ENDLOOP' or (words[0] == 'END' and len(words) > 1 and words[1] == 'LOOP'):
1023
+ elif words[0] == "ENDLOOP" or (
1024
+ words[0] == "END" and len(words) > 1 and words[1] == "LOOP"
1025
+ ):
871
1026
  depth -= 1
872
1027
  if depth == 0:
873
1028
  if len(self.variables.loop_stack) > 0:
@@ -876,14 +1031,18 @@ class CstolScriptEngine(ScriptEngine):
876
1031
  raise ValueError(f"No matching ENDLOOP found for ESCAPE command at line {line_no}")
877
1032
 
878
1033
  def handle_go(self, tokens, lines, line_no):
879
- if tokens[0].upper() == 'GOTO' or (len(tokens) > 1 and tokens[0].upper() == 'GO' and tokens[1].upper() == 'TO'):
880
- if (tokens[0].upper() == 'GOTO' and len(tokens) < 2) or (tokens[0].upper() == 'GO' and len(tokens) < 3):
1034
+ if tokens[0].upper() == "GOTO" or (
1035
+ len(tokens) > 1 and tokens[0].upper() == "GO" and tokens[1].upper() == "TO"
1036
+ ):
1037
+ if (tokens[0].upper() == "GOTO" and len(tokens) < 2) or (
1038
+ tokens[0].upper() == "GO" and len(tokens) < 3
1039
+ ):
881
1040
  raise ValueError(f"Invalid GOTO command format at line {line_no}")
882
1041
  label = None
883
- if tokens[0].upper() == 'GOTO':
884
- label = (tokens[1] + ':').upper()
1042
+ if tokens[0].upper() == "GOTO":
1043
+ label = (tokens[1] + ":").upper()
885
1044
  else:
886
- label = (tokens[2] + ':').upper()
1045
+ label = (tokens[2] + ":").upper()
887
1046
  # Find the label in the lines
888
1047
  for i in range(1, len(lines)):
889
1048
  next_line = lines[i - 1].strip().upper()
@@ -898,7 +1057,10 @@ class CstolScriptEngine(ScriptEngine):
898
1057
  # Evaluate the expression and store the result in the variable
899
1058
  result = self.evaluate_expression(expression_tokens)
900
1059
  except Exception as e:
901
- raise ValueError(f"Error evaluating expression '{expression_tokens}' in IF command at line {line_no}: {e}")
1060
+ raise ValueError(
1061
+ f"Error evaluating expression '{expression_tokens}' "
1062
+ f"in IF command at line {line_no}: {e}"
1063
+ )
902
1064
 
903
1065
  if result:
904
1066
  # Mark if as handled
@@ -910,21 +1072,18 @@ class CstolScriptEngine(ScriptEngine):
910
1072
  depth = 1
911
1073
  for i in range(line_no, len(lines)):
912
1074
  next_line = lines[i].strip().upper()
913
- if next_line.startswith('IF '):
1075
+ if next_line.startswith("IF "):
914
1076
  depth += 1
915
- elif next_line.startswith('ENDIF') or next_line.startswith('END IF'):
1077
+ elif next_line.startswith(("ENDIF", "END IF")):
916
1078
  depth -= 1
917
1079
  if depth == 0:
918
1080
  return i + 1
919
- elif next_line.startswith('ELSE'):
920
- if next_line.startswith('ELSEIF') or next_line.startswith('ELSE IF'):
1081
+ elif next_line.startswith("ELSE") and depth == 1:
1082
+ if next_line.startswith(("ELSEIF", "ELSE IF")):
921
1083
  # Continue to the ELSE IF
922
- if depth == 1:
923
- return i + 1
924
- else:
925
- # Regular ELSE - Continue to line after
926
- if depth == 1:
927
- return i + 2
1084
+ return i + 1
1085
+ # Regular ELSE - Continue to line after
1086
+ return i + 2
928
1087
 
929
1088
  raise ValueError(f"No matching ENDIF or ELSE found for IF command at line {line_no}")
930
1089
 
@@ -935,14 +1094,16 @@ class CstolScriptEngine(ScriptEngine):
935
1094
  variable_name = tokens[1]
936
1095
  item_name = None
937
1096
  expression_tokens = None
938
- if not variable_name.startswith('$'):
939
- if tokens[2] != '=':
1097
+ if not variable_name.startswith("$"):
1098
+ if tokens[2] != "=":
940
1099
  # Global variable for set_tlm
941
1100
  item_name = tokens[2]
942
1101
  expression_tokens = tokens[4:]
943
1102
  else:
944
- raise ValueError(f"Non-global variable name must start with '$' in LET command at line {line_no}")
945
- elif tokens[2] == '=':
1103
+ raise ValueError(
1104
+ f"Non-global variable name must start with '$' in LET command at line {line_no}"
1105
+ )
1106
+ elif tokens[2] == "=":
946
1107
  # Local or Special variable
947
1108
  expression_tokens = tokens[3:]
948
1109
  else:
@@ -953,14 +1114,17 @@ class CstolScriptEngine(ScriptEngine):
953
1114
  # Evaluate the expression and store the result in the variable
954
1115
  result = self.evaluate_expression(expression_tokens)
955
1116
  except Exception as e:
956
- raise ValueError(f"Error evaluating expression '{expression_tokens}' in LET command at line {line_no}: {e}")
1117
+ raise ValueError(
1118
+ f"Error evaluating expression '{expression_tokens}' "
1119
+ f"in LET command at line {line_no}: {e}"
1120
+ )
957
1121
 
958
1122
  if item_name:
959
1123
  if isinstance(result, str):
960
1124
  set_tlm(f"{variable_name} LATEST {item_name} = '{result}'")
961
1125
  else:
962
1126
  set_tlm(f"{variable_name} LATEST {item_name} = {result}")
963
- elif variable_name.startswith('$$'):
1127
+ elif variable_name.startswith("$$"):
964
1128
  # Special variable
965
1129
  self.variables.set_special_variable(variable_name, result)
966
1130
  else:
@@ -978,9 +1142,9 @@ class CstolScriptEngine(ScriptEngine):
978
1142
  raise ValueError(f"Invalid LOAD command format at line {line_no}")
979
1143
  location = expressions[0]
980
1144
  filename = expressions[1]
1145
+ # The evaluated location is unused, COSMOS sends the whole file to the interface
981
1146
  results = self.evaluate_expressions([interface_name, location, filename])
982
1147
  interface_name = results[0]
983
- location = results[1]
984
1148
  filename = results[2]
985
1149
  file = get_target_file(filename)
986
1150
  data = file.read()
@@ -1003,19 +1167,20 @@ class CstolScriptEngine(ScriptEngine):
1003
1167
  # Variables
1004
1168
  index = 0
1005
1169
  for token in tokens[2:]:
1006
- if token == ',':
1170
+ if token == ",":
1007
1171
  continue
1008
- elif token.startswith('$'):
1172
+ elif token.startswith("$"):
1009
1173
  self.variables.set_local_variable(token, os.getenv(f"CSTOL_ARG_{index}"))
1010
1174
  index += 1
1011
1175
  else:
1012
- raise ValueError(f"Invalid variable '{token}' in PROC command at line {line_no}")
1176
+ raise ValueError(
1177
+ f"Invalid variable '{token}' in PROC command at line {line_no}"
1178
+ )
1013
1179
 
1014
1180
  def handle_return(self, tokens, lines, line_no):
1015
- if len(tokens) > 1:
1016
- if tokens[1].upper() == 'ALL':
1017
- raise StopScriptError
1018
- return (len(lines) + 1)
1181
+ if len(tokens) > 1 and tokens[1].upper() == "ALL":
1182
+ raise StopScriptError
1183
+ return len(lines) + 1
1019
1184
 
1020
1185
  def handle_run(self, tokens, line_no):
1021
1186
  if len(tokens) < 2:
@@ -1026,7 +1191,7 @@ class CstolScriptEngine(ScriptEngine):
1026
1191
  results = self.evaluate_expressions(expressions)
1027
1192
  string_results = [('"' + str(result) + '"') for result in results]
1028
1193
  string_results.insert(0, script)
1029
- script = ' '.join(string_results)
1194
+ script = " ".join(string_results)
1030
1195
  print(f"Running system call: {script}")
1031
1196
  os.system(script)
1032
1197
 
@@ -1045,7 +1210,9 @@ class CstolScriptEngine(ScriptEngine):
1045
1210
  # START proc-name [argument-list]
1046
1211
  proc_name = tokens[1]
1047
1212
 
1048
- if (proc_name.startswith('"') and proc_name.endswith('"')) or (proc_name.startswith("'") and proc_name.endswith("'")):
1213
+ if (proc_name.startswith('"') and proc_name.endswith('"')) or (
1214
+ proc_name.startswith("'") and proc_name.endswith("'")
1215
+ ):
1049
1216
  # Remove quotes
1050
1217
  proc_name = proc_name[1:-1]
1051
1218
 
@@ -1062,12 +1229,12 @@ class CstolScriptEngine(ScriptEngine):
1062
1229
 
1063
1230
  def handle_switch(self, tokens, line_no):
1064
1231
  action = tokens[1].upper()
1065
- if action not in ['ON', 'OFF']:
1232
+ if action not in ["ON", "OFF"]:
1066
1233
  raise ValueError(f"Invalid SWITCH action '{action}' at line {line_no}")
1067
1234
  interface_name = tokens[2]
1068
- if action == 'ON':
1235
+ if action == "ON":
1069
1236
  connect_interface(interface_name)
1070
- elif action == 'OFF':
1237
+ elif action == "OFF":
1071
1238
  disconnect_interface(interface_name)
1072
1239
 
1073
1240
  def handle_wait(self, tokens, line_no):
@@ -1088,30 +1255,40 @@ class CstolScriptEngine(ScriptEngine):
1088
1255
  raise ValueError(f"Invalid timestamp format at line {line_no}")
1089
1256
  else:
1090
1257
  # Conditional expression wait
1091
- expressions = self.extract_expressions(tokens[1:], seperator="OR")
1092
- python_expression = self.build_python_expression(expressions[0])
1093
- if len(expressions) > 1:
1258
+ condition_tokens, timeout_tokens = self._split_wait_timeout(tokens[1:])
1259
+ python_expression = self.build_python_expression(condition_tokens)
1260
+ if timeout_tokens is not None:
1094
1261
  # Timeout given with "OR FOR" or "OR UNTIL"
1095
- seconds = self.evaluate_expression(expressions[1][1:]) # Drop assumed FOR or UNTIL token
1262
+ seconds = self.evaluate_expression(timeout_tokens[1:]) # Drop FOR or UNTIL token
1096
1263
  if isinstance(seconds, (int, float, complex)) and not isinstance(seconds, bool):
1097
1264
  if seconds > self.ONE_YEAR_SECONDS:
1098
1265
  now = datetime.datetime.now().timestamp()
1099
1266
  seconds = seconds - now
1100
1267
  if seconds < 0:
1101
1268
  seconds = 0.0
1102
- result = wait_expression(python_expression, seconds, self.variables.get_special_variable("$$CHECK_INTERVAL"), globals={'math': math, 'os': os, 'tlm': tlm})
1269
+ result = wait_expression(
1270
+ python_expression,
1271
+ seconds,
1272
+ self.variables.get_special_variable(SpecialVars.CHECK_INTERVAL),
1273
+ globals={"math": math, "os": os, "tlm": tlm},
1274
+ )
1103
1275
  if result:
1104
- self.variables.set_special_variable("$$ERROR", "NO_ERROR")
1276
+ self.variables.set_special_variable(SpecialVars.ERROR, "NO_ERROR")
1105
1277
  else:
1106
- self.variables.set_special_variable("$$ERROR", "TIME_OUT")
1278
+ self.variables.set_special_variable(SpecialVars.ERROR, "TIME_OUT")
1107
1279
  else:
1108
1280
  raise ValueError(f"Invalid timestamp format at line {line_no}")
1109
1281
  else:
1110
- result = wait_expression(python_expression, 1000000000, self.variables.get_special_variable("$$CHECK_INTERVAL"), globals={'math': math, 'os': os, 'tlm': tlm}) # Effective infinite wait
1282
+ result = wait_expression(
1283
+ python_expression,
1284
+ 1000000000,
1285
+ self.variables.get_special_variable(SpecialVars.CHECK_INTERVAL),
1286
+ globals={"math": math, "os": os, "tlm": tlm},
1287
+ ) # Effective infinite wait
1111
1288
  if result:
1112
- self.variables.set_special_variable("$$ERROR", "NO_ERROR")
1289
+ self.variables.set_special_variable(SpecialVars.ERROR, "NO_ERROR")
1113
1290
  else:
1114
- self.variables.set_special_variable("$$ERROR", "TIME_OUT")
1291
+ self.variables.set_special_variable(SpecialVars.ERROR, "TIME_OUT")
1115
1292
 
1116
1293
  def handle_write(self, tokens, line_no):
1117
1294
  expressions = self.extract_expressions(tokens[1:], ",")
@@ -1119,48 +1296,13 @@ class CstolScriptEngine(ScriptEngine):
1119
1296
  for expr in expressions:
1120
1297
  if len(expr) == 0:
1121
1298
  raise ValueError(f"Empty expression in WRITE command at line {line_no}")
1122
- result = ''
1123
- if expr[0][0] == '%':
1124
- # Format string
1125
- # %X or %x Output in hexadecimal values Value is converted to an integer prior to applying the format
1126
- # %O or %o Output in octal values Value is converted to an integer prior to applying the format
1127
- # %B or %b Output in binary values Value is converted to an integer prior to applying the format
1128
- # %I or %i Output in decimal values Value is converted to an integer prior to applying the format
1129
- # %D or %d Output in decimal values Value is converted to an integer prior to applying the format
1130
- # %F or %f Output in floating point values Default for integer or raw value
1131
- # %E or %e Output in floating point values Default for float or EU value
1132
- format = expr[0][1:].upper()
1133
- if format not in ['X', 'O', 'B', 'I', 'D', 'F', 'E']:
1134
- raise ValueError(f"Invalid format %'{format}' in WRITE command at line {line_no}")
1135
- if len(expr) < 2:
1136
- raise ValueError(f"Missing value for format %'{format}' in WRITE command at line {line_no}")
1137
- value = self.evaluate_expression(expr[1:])
1138
- if format in ['X', 'O', 'B', 'I', 'D']:
1139
- # Convert to integer
1140
- value = int(value)
1141
- if format == 'X':
1142
- result = (f"{value:X}")
1143
- elif format == 'O':
1144
- result = (f"{value:o}")
1145
- elif format == 'B':
1146
- result = (f"{value:b}")
1147
- elif format in ['I', 'D']:
1148
- result = str(value)
1149
- elif format in ['F', 'E']:
1150
- # Convert to float
1151
- value = float(value)
1152
- if format == 'F':
1153
- result = f"{value:.6f}"
1154
- elif format == 'E':
1155
- result = f"{value:.6e}"
1156
- else:
1157
- result = str(self.evaluate_expression(expr))
1158
- results.append(result)
1299
+ format_spec, expr = self.parse_format(expr, "WRITE", line_no)
1300
+ results.append(self.apply_format(format_spec, self.evaluate_expression(expr)))
1159
1301
  # Join the results with spaces
1160
- output = ' '.join(results)
1302
+ output = " ".join(results)
1161
1303
  print(output)
1162
1304
 
1163
- def run_text(self, text, filename = None, line_no = 1, end_line_no = None, bind_variables = False):
1305
+ def run_text(self, text, filename=None, line_no=1, end_line_no=None, bind_variables=False):
1164
1306
  saved_variables = self.variables
1165
1307
  try:
1166
1308
  if not bind_variables:
@@ -1181,20 +1323,20 @@ class CstolScriptEngine(ScriptEngine):
1181
1323
  if self.saved_tokens:
1182
1324
  tokens = self.saved_tokens + tokens
1183
1325
  self.saved_tokens = None
1184
- if tokens[-1] == '&':
1185
- self.saved_tokens = tokens[:-1] # Everything before the '&' is saved for the next line
1326
+ if tokens[-1] == "&":
1327
+ self.saved_tokens = tokens[:-1] # Everything before the '&' is saved for the next line
1186
1328
  return line_no + 1 # Skip to the next line
1187
1329
 
1188
1330
  # Remove any trailing comments
1189
- if ';' in tokens:
1190
- comment_index = tokens.index(';')
1331
+ if ";" in tokens:
1332
+ comment_index = tokens.index(";")
1191
1333
  tokens = tokens[:comment_index]
1192
1334
 
1193
1335
  if len(tokens) != 0:
1194
1336
  keyword = tokens[0].upper()
1195
1337
 
1196
1338
  # Handle labels
1197
- if keyword.endswith(':'):
1339
+ if keyword.endswith(":"):
1198
1340
  if len(tokens) > 1:
1199
1341
  tokens = tokens[1:]
1200
1342
  keyword = tokens[0].upper()
@@ -1205,20 +1347,75 @@ class CstolScriptEngine(ScriptEngine):
1205
1347
  case "ASK":
1206
1348
  self.handle_ask(tokens, line_no)
1207
1349
  case "BEGIN":
1208
- pass
1209
- case "CANCEL" | "CHECKPOINT" | "COMMIT" | "COMPILE" | "CSTOL" | "DECOMPILE" | "DEFINE" | "DELETE" | \
1210
- "FLUSH" | "INSERT" | "LOCK" | "MACRO" | "RECORD" | "REPORT" | "RESTORE" | "RETREIVE" | "RETRY" | \
1211
- "SHOW" | "SNAP" | "STOP" | "UNDEFINE" | "UNLOCK" | "UPDATE" | "ROUTE":
1350
+ # BEGIN only marks the start of the procedure body
1351
+ pass
1352
+ case (
1353
+ "CANCEL"
1354
+ | "CHECKPOINT"
1355
+ | "COMMIT"
1356
+ | "COMPILE"
1357
+ | "CSTOL"
1358
+ | "DECOMPILE"
1359
+ | "DEFINE"
1360
+ | "DELETE"
1361
+ | "FLUSH"
1362
+ | "INSERT"
1363
+ | "LOCK"
1364
+ | "MACRO"
1365
+ | "RECORD"
1366
+ | "REPORT"
1367
+ | "RESTORE"
1368
+ | "RETREIVE"
1369
+ | "RETRY"
1370
+ | "SHOW"
1371
+ | "SNAP"
1372
+ | "STOP"
1373
+ | "UNDEFINE"
1374
+ | "UNLOCK"
1375
+ | "UPDATE"
1376
+ | "ROUTE"
1377
+ ):
1212
1378
  # These keywords are noops for this CSTOL script engine
1213
1379
  print(f"Ignoring Unsupported Keyword: {keyword}")
1214
1380
  case "CHECK":
1215
1381
  self.handle_check(tokens, line_no)
1216
1382
  case "CLEAR":
1217
1383
  self.handle_clear(tokens, line_no)
1218
- case "ACTIVATE" | "ARM" | "BOOT" | "CHANGE" | "CLOSE" | "CMD" | "DISABLE" | "DISARM" | "DRIVE" | \
1219
- "DUMP" | "ENABLE" | "FIRE" | "FLYBACK" | "FORCE" | "GET" | "HALT" | "HOLD" | "IGNORE" | "INITIATE" | \
1220
- "MOVE" | "NOW" | "OPEN" | "PASS" | "PERFORM" | "RESET" | "SELECT" | "SET" | "SLEW" | "STEP" | "TEST" | \
1221
- "TOGGLE" | "TURN" | "USE":
1384
+ case (
1385
+ "ACTIVATE"
1386
+ | "ARM"
1387
+ | "BOOT"
1388
+ | "CHANGE"
1389
+ | "CLOSE"
1390
+ | "CMD"
1391
+ | "DISABLE"
1392
+ | "DISARM"
1393
+ | "DRIVE"
1394
+ | "DUMP"
1395
+ | "ENABLE"
1396
+ | "FIRE"
1397
+ | "FLYBACK"
1398
+ | "FORCE"
1399
+ | "GET"
1400
+ | "HALT"
1401
+ | "HOLD"
1402
+ | "IGNORE"
1403
+ | "INITIATE"
1404
+ | "MOVE"
1405
+ | "NOW"
1406
+ | "OPEN"
1407
+ | "PASS"
1408
+ | "PERFORM"
1409
+ | "RESET"
1410
+ | "SELECT"
1411
+ | "SET"
1412
+ | "SLEW"
1413
+ | "STEP"
1414
+ | "TEST"
1415
+ | "TOGGLE"
1416
+ | "TURN"
1417
+ | "USE"
1418
+ ):
1222
1419
  self.handle_cmd(tokens, line_no)
1223
1420
  case "DECLARE":
1224
1421
  self.handle_declare(tokens, line_no)
@@ -1261,4 +1458,4 @@ class CstolScriptEngine(ScriptEngine):
1261
1458
  case _:
1262
1459
  raise ValueError(f"Unknown keyword '{keyword}' at line {line_no}")
1263
1460
 
1264
- return line_no + 1
1461
+ return line_no + 1