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