openc3-cosmos-script-engine-cstol 1.1.1 → 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 +573 -399
  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
@@ -534,7 +610,7 @@ class CstolScriptEngine(ScriptEngine):
534
610
  # Earlier OR tokens are boolean operators inside the condition.
535
611
  for i in range(len(tokens) - 1, 0, -1):
536
612
  if tokens[i - 1].upper() == "OR" and tokens[i].upper() in ("FOR", "UNTIL"):
537
- return tokens[:i - 1], tokens[i:]
613
+ return tokens[: i - 1], tokens[i:]
538
614
  return tokens, None
539
615
 
540
616
  def split_vs_tokens_on_colon(self, tokens):
@@ -546,12 +622,12 @@ class CstolScriptEngine(ScriptEngine):
546
622
  """
547
623
  result = []
548
624
  for token in tokens:
549
- if ':' in token and not re.fullmatch(self.TIMESTAMP_PATTERN, token):
625
+ if ":" in token and not re.fullmatch(self.TIMESTAMP_PATTERN, token):
550
626
  # Split non-timestamp tokens containing colons
551
- parts = token.split(':')
627
+ parts = token.split(":")
552
628
  for i, part in enumerate(parts):
553
629
  if i > 0:
554
- result.append(':')
630
+ result.append(":")
555
631
  if part: # Only add non-empty parts
556
632
  result.append(part)
557
633
  else:
@@ -568,7 +644,7 @@ class CstolScriptEngine(ScriptEngine):
568
644
  result = None
569
645
  try:
570
646
  # Evaluate the expression and return the result
571
- result = eval(python_expression, {'math': math, 'os': os, 'tlm': tlm})
647
+ result = eval(python_expression, {"math": math, "os": os, "tlm": tlm})
572
648
  except Exception as e:
573
649
  raise ValueError(f"Error evaluating expression '{python_expression}': {e}")
574
650
  return result
@@ -594,7 +670,9 @@ class CstolScriptEngine(ScriptEngine):
594
670
 
595
671
  variable = tokens[1]
596
672
  question = tokens[2]
597
- 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
+ ):
598
676
  # Remove quotes
599
677
  question = question[1:-1]
600
678
  answer = ask_string(question)
@@ -602,7 +680,7 @@ class CstolScriptEngine(ScriptEngine):
602
680
  if len(answer) >= 2 and answer[0] == '"' and answer[-1] == '"':
603
681
  # Remove quotes and don't uppercase and don't eval
604
682
  answer = answer[1:-1]
605
- elif answer.strip() == '':
683
+ elif answer.strip() == "":
606
684
  # Empty answer - store as-is without tokenizing/evaluating
607
685
  pass
608
686
  else:
@@ -617,28 +695,43 @@ class CstolScriptEngine(ScriptEngine):
617
695
  answer = answer.upper()
618
696
  self.variables.set_local_variable(variable, answer)
619
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
+
620
729
  def handle_check(self, tokens, line_no):
621
730
  expressions = self.extract_expressions(tokens[1:], ",")
622
731
  for expr in expressions:
623
732
  if len(expr) == 0:
624
733
  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:]
734
+ format_spec, expr = self.parse_format(expr, "CHECK", line_no)
642
735
 
643
736
  # Check for VS
644
737
  success = True
@@ -659,7 +752,9 @@ class CstolScriptEngine(ScriptEngine):
659
752
  range_start = self.evaluate_expression(colon_expressions[0])
660
753
  range_end = self.evaluate_expression(colon_expressions[1])
661
754
  if range_start > range_end:
662
- 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
+ )
663
758
  if (value < range_start) or (value > range_end):
664
759
  success = False
665
760
  fail_message = f"not in range {range_start}:{range_end}"
@@ -670,31 +765,11 @@ class CstolScriptEngine(ScriptEngine):
670
765
  success = False
671
766
  fail_message = f"not equal to {vs_value}"
672
767
  else:
673
- # No VS - Just a print check
768
+ # No VS - Just a print check
674
769
  source = " ".join(expr)
675
770
  value = self.evaluate_expression(expr)
676
771
 
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}"
772
+ formatted_value = self.apply_format(format_spec, value)
698
773
 
699
774
  if success:
700
775
  print(f"CHECK SUCCESS: {source} = {formatted_value}")
@@ -704,37 +779,73 @@ class CstolScriptEngine(ScriptEngine):
704
779
  raise CheckError(message)
705
780
 
706
781
  def handle_clear(self, tokens, line_no):
707
- """ Only handles displays """
708
- if tokens[1].upper() == 'ALL':
782
+ """Only handles displays"""
783
+ if tokens[1].upper() == "ALL":
709
784
  clear_all_screens()
710
785
  else:
711
786
  screen_name = tokens[1]
712
- 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
+ ):
713
790
  # Remove quotes
714
791
  screen_name = screen_name[1:-1]
715
792
  clear_screen(*screen_name.split())
716
793
 
717
794
  def handle_cmd(self, tokens, line_no):
718
- if tokens[0].upper() == 'NOW':
795
+ if tokens[0].upper() == "NOW":
719
796
  # Drop NOW
720
797
  tokens = tokens[1:]
721
798
 
722
799
  verb = tokens[0].upper()
723
800
  tokens = tokens[1:]
724
801
  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":
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
+ ):
729
836
  # Handle all the weird verbs
730
837
  if verb == "TURN" or verb == "FORCE":
731
838
  # Expect ON or OFF to follow
732
- if tokens[0].upper() == 'ON' or tokens[0].upper() == 'OFF':
839
+ if tokens[0].upper() == "ON" or tokens[0].upper() == "OFF":
733
840
  verb = verb + tokens[0].upper()
734
841
  tokens = tokens[1:]
735
842
  else:
736
- 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
+ )
737
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
738
849
  pass
739
850
 
740
851
  # Now we need to discover any TO, BY, FROM, WITH clauses
@@ -796,24 +907,29 @@ class CstolScriptEngine(ScriptEngine):
796
907
  cmd(target_name, cmd_name, args)
797
908
 
798
909
  def handle_declare(self, tokens, line_no):
799
- """ We ignore ranges and allowed value lists """
910
+ """We ignore ranges and allowed value lists"""
800
911
  # DECLARE mode variable-name = default-value [range value-list]
801
912
  mode = tokens[1].upper()
802
- if mode not in ['INPUT', 'VARIABLE', 'CONSTANT']:
913
+ if mode not in ["INPUT", "VARIABLE", "CONSTANT"]:
803
914
  raise ValueError(f"Invalid mode '{mode}' in DECLARE command at line {line_no}")
804
915
  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}")
916
+ if not variable_name.startswith("$"):
917
+ raise ValueError(
918
+ f"Variable name must start with '$' in DECLARE command at line {line_no}"
919
+ )
807
920
  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])
921
+ if equals != "=":
922
+ raise ValueError(
923
+ f"Expected '=' after variable name in DECLARE command at line {line_no}"
924
+ )
811
925
  default_value = self.evaluate_tokens([tokens[4]])[0]
812
926
  self.variables.set_local_variable(variable_name, default_value)
813
927
 
814
928
  def handle_display(self, tokens, line_no):
815
929
  screen_name = tokens[1]
816
- 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
+ ):
817
933
  # Remove quotes
818
934
  screen_name = screen_name[1:-1]
819
935
  display_screen(*screen_name.split())
@@ -822,32 +938,32 @@ class CstolScriptEngine(ScriptEngine):
822
938
  goto_endif = False
823
939
  if len(tokens) > 1:
824
940
  # 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
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
827
943
  current_if = False
828
944
  if len(self.variables.if_stack) > 0:
829
945
  current_if = self.variables.if_stack[-1]
830
- if tokens[0].upper() == 'ELSEIF':
946
+ if tokens[0].upper() == "ELSEIF":
831
947
  if current_if:
832
948
  goto_endif = True
833
949
  else:
834
950
  return self.handle_if(tokens, lines, line_no)
835
- elif (tokens[0].upper() == 'ELSE' and tokens[1].upper() == 'IF'):
951
+ elif tokens[0].upper() == "ELSE" and tokens[1].upper() == "IF":
836
952
  if current_if:
837
953
  goto_endif = True
838
954
  else:
839
955
  return self.handle_if(tokens[1:], lines, line_no)
840
956
 
841
- 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"):
842
958
  # The only way we ever hit an ELSE is if we were in a successful block beforehand
843
959
  # Therefore goto the ENDIF
844
960
  self.variables.if_stack[-1] = True
845
961
  depth = 1
846
962
  for i in range(line_no, len(lines)):
847
963
  next_line = lines[i].strip().upper()
848
- if next_line.startswith('IF '):
964
+ if next_line.startswith("IF "):
849
965
  depth += 1
850
- elif next_line.startswith('ENDIF') or next_line.startswith('END IF'):
966
+ elif next_line.startswith(("ENDIF", "END IF")):
851
967
  depth -= 1
852
968
  if depth == 0:
853
969
  return i + 1
@@ -856,29 +972,43 @@ class CstolScriptEngine(ScriptEngine):
856
972
  raise ValueError(f"handle_else called with unexpected tokens at line {line_no}")
857
973
 
858
974
  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'):
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
+ ):
862
983
  # END IF pops the IF stack
863
984
  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]
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)
880
989
  return line_no + 1
881
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
+
882
1012
  def handle_escape(self, _tokens, lines, line_no):
883
1013
  # Find the matching ENDLOOP
884
1014
  depth = 1
@@ -888,9 +1018,11 @@ class CstolScriptEngine(ScriptEngine):
888
1018
  words = lines[i].strip().upper().split()
889
1019
  if not words:
890
1020
  continue
891
- if words[0] == 'LOOP':
1021
+ if words[0] == "LOOP":
892
1022
  depth += 1
893
- 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
+ ):
894
1026
  depth -= 1
895
1027
  if depth == 0:
896
1028
  if len(self.variables.loop_stack) > 0:
@@ -899,14 +1031,18 @@ class CstolScriptEngine(ScriptEngine):
899
1031
  raise ValueError(f"No matching ENDLOOP found for ESCAPE command at line {line_no}")
900
1032
 
901
1033
  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):
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
+ ):
904
1040
  raise ValueError(f"Invalid GOTO command format at line {line_no}")
905
1041
  label = None
906
- if tokens[0].upper() == 'GOTO':
907
- label = (tokens[1] + ':').upper()
1042
+ if tokens[0].upper() == "GOTO":
1043
+ label = (tokens[1] + ":").upper()
908
1044
  else:
909
- label = (tokens[2] + ':').upper()
1045
+ label = (tokens[2] + ":").upper()
910
1046
  # Find the label in the lines
911
1047
  for i in range(1, len(lines)):
912
1048
  next_line = lines[i - 1].strip().upper()
@@ -921,7 +1057,10 @@ class CstolScriptEngine(ScriptEngine):
921
1057
  # Evaluate the expression and store the result in the variable
922
1058
  result = self.evaluate_expression(expression_tokens)
923
1059
  except Exception as e:
924
- 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
+ )
925
1064
 
926
1065
  if result:
927
1066
  # Mark if as handled
@@ -933,21 +1072,18 @@ class CstolScriptEngine(ScriptEngine):
933
1072
  depth = 1
934
1073
  for i in range(line_no, len(lines)):
935
1074
  next_line = lines[i].strip().upper()
936
- if next_line.startswith('IF '):
1075
+ if next_line.startswith("IF "):
937
1076
  depth += 1
938
- elif next_line.startswith('ENDIF') or next_line.startswith('END IF'):
1077
+ elif next_line.startswith(("ENDIF", "END IF")):
939
1078
  depth -= 1
940
1079
  if depth == 0:
941
1080
  return i + 1
942
- elif next_line.startswith('ELSE'):
943
- 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")):
944
1083
  # 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
1084
+ return i + 1
1085
+ # Regular ELSE - Continue to line after
1086
+ return i + 2
951
1087
 
952
1088
  raise ValueError(f"No matching ENDIF or ELSE found for IF command at line {line_no}")
953
1089
 
@@ -958,14 +1094,16 @@ class CstolScriptEngine(ScriptEngine):
958
1094
  variable_name = tokens[1]
959
1095
  item_name = None
960
1096
  expression_tokens = None
961
- if not variable_name.startswith('$'):
962
- if tokens[2] != '=':
1097
+ if not variable_name.startswith("$"):
1098
+ if tokens[2] != "=":
963
1099
  # Global variable for set_tlm
964
1100
  item_name = tokens[2]
965
1101
  expression_tokens = tokens[4:]
966
1102
  else:
967
- raise ValueError(f"Non-global variable name must start with '$' in LET command at line {line_no}")
968
- 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] == "=":
969
1107
  # Local or Special variable
970
1108
  expression_tokens = tokens[3:]
971
1109
  else:
@@ -976,14 +1114,17 @@ class CstolScriptEngine(ScriptEngine):
976
1114
  # Evaluate the expression and store the result in the variable
977
1115
  result = self.evaluate_expression(expression_tokens)
978
1116
  except Exception as e:
979
- 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
+ )
980
1121
 
981
1122
  if item_name:
982
1123
  if isinstance(result, str):
983
1124
  set_tlm(f"{variable_name} LATEST {item_name} = '{result}'")
984
1125
  else:
985
1126
  set_tlm(f"{variable_name} LATEST {item_name} = {result}")
986
- elif variable_name.startswith('$$'):
1127
+ elif variable_name.startswith("$$"):
987
1128
  # Special variable
988
1129
  self.variables.set_special_variable(variable_name, result)
989
1130
  else:
@@ -1001,9 +1142,9 @@ class CstolScriptEngine(ScriptEngine):
1001
1142
  raise ValueError(f"Invalid LOAD command format at line {line_no}")
1002
1143
  location = expressions[0]
1003
1144
  filename = expressions[1]
1145
+ # The evaluated location is unused, COSMOS sends the whole file to the interface
1004
1146
  results = self.evaluate_expressions([interface_name, location, filename])
1005
1147
  interface_name = results[0]
1006
- location = results[1]
1007
1148
  filename = results[2]
1008
1149
  file = get_target_file(filename)
1009
1150
  data = file.read()
@@ -1026,19 +1167,20 @@ class CstolScriptEngine(ScriptEngine):
1026
1167
  # Variables
1027
1168
  index = 0
1028
1169
  for token in tokens[2:]:
1029
- if token == ',':
1170
+ if token == ",":
1030
1171
  continue
1031
- elif token.startswith('$'):
1172
+ elif token.startswith("$"):
1032
1173
  self.variables.set_local_variable(token, os.getenv(f"CSTOL_ARG_{index}"))
1033
1174
  index += 1
1034
1175
  else:
1035
- 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
+ )
1036
1179
 
1037
1180
  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)
1181
+ if len(tokens) > 1 and tokens[1].upper() == "ALL":
1182
+ raise StopScriptError
1183
+ return len(lines) + 1
1042
1184
 
1043
1185
  def handle_run(self, tokens, line_no):
1044
1186
  if len(tokens) < 2:
@@ -1049,7 +1191,7 @@ class CstolScriptEngine(ScriptEngine):
1049
1191
  results = self.evaluate_expressions(expressions)
1050
1192
  string_results = [('"' + str(result) + '"') for result in results]
1051
1193
  string_results.insert(0, script)
1052
- script = ' '.join(string_results)
1194
+ script = " ".join(string_results)
1053
1195
  print(f"Running system call: {script}")
1054
1196
  os.system(script)
1055
1197
 
@@ -1068,7 +1210,9 @@ class CstolScriptEngine(ScriptEngine):
1068
1210
  # START proc-name [argument-list]
1069
1211
  proc_name = tokens[1]
1070
1212
 
1071
- 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
+ ):
1072
1216
  # Remove quotes
1073
1217
  proc_name = proc_name[1:-1]
1074
1218
 
@@ -1085,12 +1229,12 @@ class CstolScriptEngine(ScriptEngine):
1085
1229
 
1086
1230
  def handle_switch(self, tokens, line_no):
1087
1231
  action = tokens[1].upper()
1088
- if action not in ['ON', 'OFF']:
1232
+ if action not in ["ON", "OFF"]:
1089
1233
  raise ValueError(f"Invalid SWITCH action '{action}' at line {line_no}")
1090
1234
  interface_name = tokens[2]
1091
- if action == 'ON':
1235
+ if action == "ON":
1092
1236
  connect_interface(interface_name)
1093
- elif action == 'OFF':
1237
+ elif action == "OFF":
1094
1238
  disconnect_interface(interface_name)
1095
1239
 
1096
1240
  def handle_wait(self, tokens, line_no):
@@ -1115,26 +1259,36 @@ class CstolScriptEngine(ScriptEngine):
1115
1259
  python_expression = self.build_python_expression(condition_tokens)
1116
1260
  if timeout_tokens is not None:
1117
1261
  # Timeout given with "OR FOR" or "OR UNTIL"
1118
- seconds = self.evaluate_expression(timeout_tokens[1:]) # Drop FOR or UNTIL token
1262
+ seconds = self.evaluate_expression(timeout_tokens[1:]) # Drop FOR or UNTIL token
1119
1263
  if isinstance(seconds, (int, float, complex)) and not isinstance(seconds, bool):
1120
1264
  if seconds > self.ONE_YEAR_SECONDS:
1121
1265
  now = datetime.datetime.now().timestamp()
1122
1266
  seconds = seconds - now
1123
1267
  if seconds < 0:
1124
1268
  seconds = 0.0
1125
- 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
+ )
1126
1275
  if result:
1127
- self.variables.set_special_variable("$$ERROR", "NO_ERROR")
1276
+ self.variables.set_special_variable(SpecialVars.ERROR, "NO_ERROR")
1128
1277
  else:
1129
- self.variables.set_special_variable("$$ERROR", "TIME_OUT")
1278
+ self.variables.set_special_variable(SpecialVars.ERROR, "TIME_OUT")
1130
1279
  else:
1131
1280
  raise ValueError(f"Invalid timestamp format at line {line_no}")
1132
1281
  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
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
1134
1288
  if result:
1135
- self.variables.set_special_variable("$$ERROR", "NO_ERROR")
1289
+ self.variables.set_special_variable(SpecialVars.ERROR, "NO_ERROR")
1136
1290
  else:
1137
- self.variables.set_special_variable("$$ERROR", "TIME_OUT")
1291
+ self.variables.set_special_variable(SpecialVars.ERROR, "TIME_OUT")
1138
1292
 
1139
1293
  def handle_write(self, tokens, line_no):
1140
1294
  expressions = self.extract_expressions(tokens[1:], ",")
@@ -1142,48 +1296,13 @@ class CstolScriptEngine(ScriptEngine):
1142
1296
  for expr in expressions:
1143
1297
  if len(expr) == 0:
1144
1298
  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)
1299
+ format_spec, expr = self.parse_format(expr, "WRITE", line_no)
1300
+ results.append(self.apply_format(format_spec, self.evaluate_expression(expr)))
1182
1301
  # Join the results with spaces
1183
- output = ' '.join(results)
1302
+ output = " ".join(results)
1184
1303
  print(output)
1185
1304
 
1186
- 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):
1187
1306
  saved_variables = self.variables
1188
1307
  try:
1189
1308
  if not bind_variables:
@@ -1204,20 +1323,20 @@ class CstolScriptEngine(ScriptEngine):
1204
1323
  if self.saved_tokens:
1205
1324
  tokens = self.saved_tokens + tokens
1206
1325
  self.saved_tokens = None
1207
- if tokens[-1] == '&':
1208
- 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
1209
1328
  return line_no + 1 # Skip to the next line
1210
1329
 
1211
1330
  # Remove any trailing comments
1212
- if ';' in tokens:
1213
- comment_index = tokens.index(';')
1331
+ if ";" in tokens:
1332
+ comment_index = tokens.index(";")
1214
1333
  tokens = tokens[:comment_index]
1215
1334
 
1216
1335
  if len(tokens) != 0:
1217
1336
  keyword = tokens[0].upper()
1218
1337
 
1219
1338
  # Handle labels
1220
- if keyword.endswith(':'):
1339
+ if keyword.endswith(":"):
1221
1340
  if len(tokens) > 1:
1222
1341
  tokens = tokens[1:]
1223
1342
  keyword = tokens[0].upper()
@@ -1228,20 +1347,75 @@ class CstolScriptEngine(ScriptEngine):
1228
1347
  case "ASK":
1229
1348
  self.handle_ask(tokens, line_no)
1230
1349
  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":
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
+ ):
1235
1378
  # These keywords are noops for this CSTOL script engine
1236
1379
  print(f"Ignoring Unsupported Keyword: {keyword}")
1237
1380
  case "CHECK":
1238
1381
  self.handle_check(tokens, line_no)
1239
1382
  case "CLEAR":
1240
1383
  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":
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
+ ):
1245
1419
  self.handle_cmd(tokens, line_no)
1246
1420
  case "DECLARE":
1247
1421
  self.handle_declare(tokens, line_no)