openc3-cosmos-script-engine-cstol 1.0.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.
@@ -0,0 +1,1195 @@
1
+ # Copyright 2025 OpenC3, Inc.
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to
5
+ # deal in the Software without restriction, including without limitation the
6
+ # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
7
+ # sell copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
18
+ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
19
+ # DEALINGS IN THE SOFTWARE.
20
+ #
21
+ # CSTOL language originally developed by the University of Colorado / LASP.
22
+
23
+ import re
24
+ import shlex
25
+ import datetime
26
+ import math
27
+ import os
28
+ from openc3.script.exceptions import CheckError, StopScript
29
+ 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
32
+
33
+ class CstolVariables:
34
+ special_variables = {
35
+ "$$OWLT": 0.0,
36
+ "$$ERROR": "NO_ERROR",
37
+ "$$CHECK_INTERVAL": 1.0,
38
+ "$$STEP_INTERVAL": 0.1,
39
+ "$$CLP_STP_INTERVAL": 0.1,
40
+ "$$CLP_STEP_MODE": "PAUSE",
41
+ "$$STEP_MODE": "PAUSE"
42
+ }
43
+
44
+ def __init__(self):
45
+ self.local_variables = {}
46
+ self.loop_stack = []
47
+
48
+ def set_special_variable(self, name, value):
49
+ """
50
+ Sets a special variable, which is a variable that starts with '$$'.
51
+ Special variables are not stored in the local variables dictionary.
52
+ """
53
+ if not name.startswith('$$'):
54
+ raise ValueError(f"Special variable names must start with '$$': {name}")
55
+ self.special_variables[name] = value
56
+ match name.upper():
57
+ case "$$CLP_STP_INTERVAL":
58
+ set_line_delay(value)
59
+ self.special_variables["$$STEP_INTERVAL"] = value
60
+ case "$$STEP_INTERVAL":
61
+ self.set_special_variable("$$CLP_STP_INTERVAL", value)
62
+ case "$$CLP_STEP_MODE":
63
+ mode = str(value).upper()
64
+ if mode == "GO":
65
+ run_mode()
66
+ set_line_delay(0)
67
+ elif mode == "PAUSE":
68
+ run_mode()
69
+ set_line_delay(self.special_variables["$$STEP_INTERVAL"])
70
+ elif mode == "WAIT":
71
+ step_mode()
72
+ else:
73
+ raise ValueError(f"Invalid step mode: {mode}")
74
+ self.special_variables["$$STEP_MODE"] = value
75
+ case "$$STEP_MODE":
76
+ self.set_special_variable("$$CLP_STEP_MODE", value)
77
+
78
+ def get_special_variable(self, name):
79
+ """
80
+ Gets a special variable by name.
81
+ Returns None if the variable does not exist.
82
+ """
83
+ if not name.startswith('$$'):
84
+ raise ValueError(f"Special variable names must start with '$$': {name}")
85
+ match name.upper():
86
+ case "$$CURRENT_TIME":
87
+ return datetime.datetime.now(datetime.timezone.utc).timestamp()
88
+ case "$$SC_TIME":
89
+ return datetime.datetime.now(datetime.timezone.utc).timestamp() + self.special_variables["$$OWLT"]
90
+ case "$$LOOP_COUNT":
91
+ if len(self.loop_stack) > 0:
92
+ return self.loop_stack[-1][2]
93
+ else:
94
+ return 0
95
+
96
+ return self.special_variables.get(name, None)
97
+
98
+ class CstolScriptEngine(ScriptEngine):
99
+
100
+ ONE_YEAR_SECONDS = 31536000
101
+
102
+ # Dictionary of known CSTOL tokens - Unknown could be numbers or parts of telemetry names
103
+ KNOWN_TOKENS = {
104
+ 'AND': 'and',
105
+ 'OR': 'or',
106
+ 'XOR': 'xor',
107
+ 'NOT': 'not',
108
+ 'FOR': '',
109
+ 'UNTIL': '',
110
+ 'MOD': '%',
111
+ '**': '**',
112
+ '*': '*',
113
+ '(': '(',
114
+ ')': ')',
115
+ '<': '<',
116
+ '>': '>',
117
+ '<=': '<=',
118
+ '>=': '>=',
119
+ '/=': '!=',
120
+ '=': '==',
121
+ '+': '+',
122
+ '-': '-',
123
+ '/': '/',
124
+ 'TRUE': 'True',
125
+ 'FALSE': 'False',
126
+ }
127
+
128
+ FUNCTION_TOKENS = {
129
+ 'SIN': 'math.sin',
130
+ 'COS': 'math.cos',
131
+ 'TAN': 'math.tan',
132
+ 'ASIN': 'math.asin',
133
+ 'ACOS': 'math.acos',
134
+ 'ATAN': 'math.atan',
135
+ 'SINH': 'math.sinh',
136
+ 'COSH': 'math.cosh',
137
+ 'TANH': 'math.tanh',
138
+ 'EXP': 'math.exp',
139
+ 'LOG': 'math.log',
140
+ 'LOG2': 'math.log2',
141
+ 'LOG10': 'math.log10',
142
+ 'SQRT': 'math.sqrt',
143
+ 'EVAL': 'eval',
144
+ 'GETENV': 'os.getenv',
145
+ }
146
+
147
+ KNOWN_UNITS = [
148
+ 'DN',
149
+ 'A',
150
+ 'C',
151
+ 'CM',
152
+ 'F',
153
+ 'FT',
154
+ 'G',
155
+ 'GHZ',
156
+ 'H',
157
+ 'HZ',
158
+ 'IN',
159
+ 'J',
160
+ 'K',
161
+ 'KG',
162
+ 'KM',
163
+ 'KOHM',
164
+ 'KV',
165
+ 'KW',
166
+ 'M',
167
+ 'MA',
168
+ 'MG',
169
+ 'MHZ',
170
+ 'MIN',
171
+ 'MM',
172
+ 'MOHM',
173
+ 'MV',
174
+ 'MW',
175
+ 'OHM',
176
+ 'PA',
177
+ 'PSI',
178
+ 'S',
179
+ 'UA',
180
+ 'UV',
181
+ 'V',
182
+ 'W',
183
+ ]
184
+
185
+ def __init__(self, running_script):
186
+ super().__init__(running_script)
187
+ self.variables = CstolVariables()
188
+ self.saved_tokens = None
189
+
190
+ def cstol_tokenizer(self, s, special_chars='()><+-*/=;,'):
191
+ tokens = self.tokenizer(s, special_chars)
192
+
193
+ # Reconstruct full timestamps of the format yyyy/doy-HH:MM:SS into single tokens
194
+ # These get broken up like ["yyyy", "/", "doy", "-", "HH:MM:SS"] by the tokenizer
195
+ reconstructed_tokens = []
196
+ i = 0
197
+ while i < len(tokens):
198
+ # Check if we have a potential timestamp pattern: yyyy/doy-HH:MM:SS
199
+ if (i + 4 < len(tokens) and
200
+ re.match(r'^\d{4}$', tokens[i]) and
201
+ tokens[i + 1] == '/' and
202
+ re.match(r'^\d{1,3}$', tokens[i + 2]) and
203
+ tokens[i + 3] == '-' and
204
+ re.match(r'^\d{1,2}:\d{1,2}:\d{1,2}\.?\d*$', tokens[i + 4])):
205
+ # Reconstruct the full timestamp
206
+ timestamp = tokens[i] + tokens[i + 1] + tokens[i + 2] + tokens[i + 3] + tokens[i + 4]
207
+ reconstructed_tokens.append(timestamp)
208
+ i += 5 # Skip the next 4 tokens since we combined them
209
+
210
+ # Check if we have a potential timestamp pattern: /doy-HH:MM:SS
211
+ elif (i + 3 < len(tokens) and
212
+ tokens[i] == '/' and
213
+ re.match(r'^\d{1,3}$', tokens[i + 1]) and
214
+ tokens[i + 2] == '-' and
215
+ re.match(r'^\d{1,2}:\d{1,2}:\d{1,2}\.?\d*$', tokens[i + 3])):
216
+ # Reconstruct the full timestamp
217
+ timestamp = tokens[i] + tokens[i + 1] + tokens[i + 2] + tokens[i + 3]
218
+ reconstructed_tokens.append(timestamp)
219
+ i += 4 # Skip the next 3 tokens since we combined them
220
+
221
+ # Check if we have a potential timestamp pattern: yyyy/-HH:MM:SS
222
+ elif (i + 3 < len(tokens) and
223
+ re.match(r'^\d{4}$', tokens[i]) and
224
+ tokens[i + 1] == '/' and
225
+ tokens[i + 2] == '-' and
226
+ re.match(r'^\d{1,2}:\d{1,2}:\d{1,2}\.?\d*$', tokens[i + 3])):
227
+ # Reconstruct the full timestamp
228
+ timestamp = tokens[i] + tokens[i + 1] + tokens[i + 2] + tokens[i + 3]
229
+ reconstructed_tokens.append(timestamp)
230
+ i += 4 # Skip the next 3 tokens since we combined them
231
+
232
+ # Check if we have a potential timestamp pattern: yyyy/-HH:MM:SS
233
+ elif (i + 2 < len(tokens) and
234
+ tokens[i] == '/' and
235
+ tokens[i + 1] == '-' and
236
+ re.match(r'^\d{1,2}:\d{1,2}:\d{1,2}\.?\d*$', tokens[i + 2])):
237
+ # Reconstruct the full timestamp
238
+ timestamp = tokens[i] + tokens[i + 1] + tokens[i + 2]
239
+ reconstructed_tokens.append(timestamp)
240
+ i += 3 # Skip the next 2 tokens since we combined them
241
+
242
+ else:
243
+ reconstructed_tokens.append(tokens[i])
244
+ i += 1
245
+
246
+ return reconstructed_tokens
247
+
248
+ def build_python_expression(self, expression_tokens):
249
+ """
250
+ Builds a Python expression from the provided tokens.
251
+
252
+ Needs to handle all the CSTOL operators and syntax, converting them into valid Python syntax.
253
+
254
+ Args:
255
+ expression_tokens: List of tokens that form a valid Python expression
256
+
257
+ Returns:
258
+ A string representing the Python expression
259
+ """
260
+ final_tokens = []
261
+ previous_number = False
262
+ for index, token in enumerate(expression_tokens):
263
+ # Handle Units
264
+ if previous_number and token.upper() in self.KNOWN_UNITS:
265
+ # Just drop the units
266
+ previous_number = False
267
+ continue
268
+ previous_number = False
269
+
270
+ # Handle special variables
271
+ if token.startswith('$$'):
272
+ # Get the actual value of special variable
273
+ value = self.variables.get_special_variable(token)
274
+ if isinstance(value, str):
275
+ final_tokens.append(f'"{value}"')
276
+ else:
277
+ final_tokens.append(str(value))
278
+ continue
279
+
280
+ # Handle local variables
281
+ if token.startswith('$'):
282
+ # Get the actual value of local variable
283
+ value = self.variables.local_variables.get(token, None)
284
+ if value is None:
285
+ raise ValueError(f"Unknown variable: {token}")
286
+ if isinstance(value, str):
287
+ final_tokens.append(f'"{value}"')
288
+ else:
289
+ final_tokens.append(str(value))
290
+ continue
291
+
292
+ # Handle CSTOL operators and convert to Python equivalents
293
+ if token.upper() in self.KNOWN_TOKENS:
294
+ token = self.KNOWN_TOKENS[token.upper()]
295
+ final_tokens.append(token)
296
+ continue
297
+
298
+ # Handle CSTOL functions
299
+ if token.upper() in self.FUNCTION_TOKENS:
300
+ # Function tokens must be followed by ( or they will be considered just strings
301
+ if len(expression_tokens) > index + 1 and expression_tokens[index + 1] == '(':
302
+ token = self.FUNCTION_TOKENS[token.upper()]
303
+ final_tokens.append(token)
304
+ else:
305
+ final_tokens.append(f'"{token}"')
306
+ continue
307
+
308
+ # Handle timestamps
309
+ timestamp = self.parse_timestamp(token)
310
+ if timestamp is not None:
311
+ final_tokens.append(str(timestamp))
312
+ continue
313
+
314
+ # Handle radix notation integers
315
+ matches = re.match(r'^[BODXHbodxh]#\d+$', token)
316
+ if matches is not None:
317
+ match (token[0].upper()):
318
+ case 'B':
319
+ final_tokens.append(f'0b{token[2:]}')
320
+ case 'O':
321
+ final_tokens.append(f'0o{token[2:]}')
322
+ case 'D':
323
+ final_tokens.append(f'{token[2:]}')
324
+ case 'H':
325
+ final_tokens.append(str(int(token[2:], 16).to_bytes((len(token[2:]) + 1) // 2, 'big')))
326
+ case 'X':
327
+ final_tokens.append(f'0x{token[2:]}')
328
+ previous_number = True
329
+ continue
330
+
331
+ # Handle numbers - Remove DN and EUs
332
+ # Use regex to extract the numeric part (including scientific notation)
333
+ # This pattern matches:
334
+ # - Optional sign (+/-)
335
+ # - Digits, optional decimal point and more digits
336
+ # - Optional scientific notation (e/E followed by optional sign and digits)
337
+ # - Ignores any alphabetic characters that follow
338
+ matches = re.match(r'^([+-]?\d*\.?\d+(?:[eE][+-]?\d+)?)', token)
339
+ if matches is not None:
340
+ final_tokens.append(str(matches.group(1)))
341
+ previous_number = True
342
+ continue
343
+
344
+ # Handle quoted strings and non-numbers
345
+ if token.startswith('"') and token.endswith('"'):
346
+ # Already quoted with double quotes
347
+ final_tokens.append(token)
348
+ elif token.startswith("'") and token.endswith("'"):
349
+ # Already quoted with single quotes
350
+ final_tokens.append(token)
351
+ else:
352
+ # Non numbers should become quoted strings
353
+ final_tokens.append(f'"{token}"')
354
+
355
+ # Second pass: look for sets of double quoted strings and convert to tlm() calls
356
+ processed_tokens = []
357
+ i = 0
358
+ while i < len(final_tokens):
359
+ token = final_tokens[i]
360
+
361
+ # Check for "RAW" pattern first: "RAW", "TARGET_NAME", "ITEM_NAME"
362
+ if (token == '"RAW"' and i + 2 < len(final_tokens) and
363
+ final_tokens[i + 1].startswith('"') and final_tokens[i + 1].endswith('"') and
364
+ final_tokens[i + 2].startswith('"') and final_tokens[i + 2].endswith('"')):
365
+
366
+ target_name = final_tokens[i + 1][1:-1] # Remove quotes
367
+ item_name = final_tokens[i + 2][1:-1] # Remove quotes
368
+ tlm_call = f'tlm("{target_name}", "LATEST", "{item_name}", type="RAW")'
369
+ processed_tokens.append(tlm_call)
370
+ i += 3 # Skip the next 2 tokens
371
+
372
+ # Check for regular pattern: "TARGET_NAME", "ITEM_NAME"
373
+ elif (token.startswith('"') and token.endswith('"') and i + 1 < len(final_tokens) and
374
+ final_tokens[i + 1].startswith('"') and final_tokens[i + 1].endswith('"')):
375
+
376
+ target_name = token[1:-1] # Remove quotes
377
+ item_name = final_tokens[i + 1][1:-1] # Remove quotes
378
+ tlm_call = f'tlm("{target_name}", "LATEST", "{item_name}")'
379
+ processed_tokens.append(tlm_call)
380
+ i += 2 # Skip the next token
381
+
382
+ else:
383
+ # Keep the original token
384
+ processed_tokens.append(token)
385
+ i += 1
386
+
387
+ # Join the processed tokens into a single string
388
+ return ' '.join(processed_tokens)
389
+
390
+ # Combined regex pattern to match both formats
391
+ TIMESTAMP_PATTERN = r'(?:(\d{4})?/(\d{1,3})?-)?(\d{1,2}):(\d{1,2}):(\d{1,2}\.?\d*)'
392
+
393
+ # Will convert a CSTOL Clock Time or Delta Time into floating point seconds
394
+ def parse_timestamp(self, timestamp, now = None):
395
+ if now is None:
396
+ now = datetime.datetime.now(datetime.timezone.utc)
397
+
398
+ matches = re.match(self.TIMESTAMP_PATTERN, timestamp)
399
+ if matches:
400
+ year, day_of_year, hour, minute, second = matches.groups()
401
+ result = {
402
+ 'hour': int(hour),
403
+ 'minute': int(minute),
404
+ 'second': float(second)
405
+ }
406
+
407
+ # Determine if / was present (meaning a clock time rather than delta time)
408
+ if '/' in timestamp:
409
+ result['clock_time'] = True
410
+ else:
411
+ result['clock_time'] = False
412
+
413
+ # Add year and day_of_year if they exist
414
+ if year is not None:
415
+ result['year'] = int(year)
416
+ elif result['clock_time']:
417
+ result['year'] = now.year
418
+
419
+ if day_of_year is not None:
420
+ result['day_of_year'] = int(day_of_year)
421
+ elif result['clock_time']:
422
+ result['day_of_year'] = now.timetuple().tm_yday
423
+
424
+ # Extract seconds and microseconds from fractional seconds
425
+ total_seconds = result['second']
426
+ seconds = int(total_seconds)
427
+ # Use round to handle floating point precision issues
428
+ microseconds = round((total_seconds - seconds) * 1000000)
429
+
430
+ if 'year' in result and 'day_of_year' in result:
431
+ # Create datetime from year and day of year
432
+ base_date = datetime.datetime(
433
+ year=result['year'],
434
+ month=1,
435
+ day=1,
436
+ tzinfo=datetime.timezone.utc
437
+ ) + datetime.timedelta(days=result['day_of_year'] - 1)
438
+
439
+ return datetime.datetime(
440
+ year=base_date.year,
441
+ month=base_date.month,
442
+ day=base_date.day,
443
+ hour=result['hour'],
444
+ minute=result['minute'],
445
+ second=seconds,
446
+ microsecond=microseconds,
447
+ tzinfo=datetime.timezone.utc
448
+ ).timestamp()
449
+ else:
450
+ # Delta Time
451
+ return datetime.timedelta(
452
+ hours=result['hour'],
453
+ minutes=result['minute'],
454
+ seconds=seconds,
455
+ microseconds=microseconds
456
+ ).total_seconds()
457
+
458
+ return None
459
+
460
+ def flatten(self, xss):
461
+ return [x for xs in xss for x in xs]
462
+
463
+ def extract_expressions(self, tokens, seperator=","):
464
+ # Split the tokens into expressions based on seperator
465
+ # This allows for multiple expressions in a single line, separated by commas
466
+ expressions = []
467
+ expression = []
468
+ for token in tokens:
469
+ if token.upper() == seperator:
470
+ expressions.append(expression)
471
+ expression = []
472
+ else:
473
+ expression.append(token)
474
+ if len(expression) > 0:
475
+ expressions.append(expression)
476
+ return expressions
477
+
478
+ def split_vs_tokens_on_colon(self, tokens):
479
+ """
480
+ Split tokens on colons for VS clause processing, but preserve timestamps.
481
+ Timestamps (yyyy/doy-HH:MM:SS or HH:MM:SS) should not be split.
482
+ Range expressions like "1V:2V" should be split into ["1V", ":", "2V"].
483
+ Handles both pre-split tokens and tokens that need splitting.
484
+ """
485
+ result = []
486
+ for token in tokens:
487
+ if ':' in token and not re.fullmatch(self.TIMESTAMP_PATTERN, token):
488
+ # Split non-timestamp tokens containing colons
489
+ parts = token.split(':')
490
+ for i, part in enumerate(parts):
491
+ if i > 0:
492
+ result.append(':')
493
+ if part: # Only add non-empty parts
494
+ result.append(part)
495
+ else:
496
+ result.append(token)
497
+ return result
498
+
499
+ def evaluate_expression(self, expression):
500
+ """
501
+ Evaluates a single CSTOL expression and returns the result.
502
+ The expression can be a variable, a number, or a complex expression.
503
+ """
504
+ # Convert the expression to a Python expression
505
+ python_expression = self.build_python_expression(expression)
506
+ result = None
507
+ try:
508
+ # Evaluate the expression and return the result
509
+ result = eval(python_expression, {'math': math, 'os': os, 'tlm': tlm})
510
+ except Exception as e:
511
+ raise ValueError(f"Error evaluating expression '{python_expression}': {e}")
512
+ return result
513
+
514
+ def evaluate_expressions(self, expressions):
515
+ # Convert each expression to a Python expression
516
+ results = []
517
+ for expr in expressions:
518
+ results.append(self.evaluate_expression(expr))
519
+
520
+ return results
521
+
522
+ def evaluate_tokens(self, tokens):
523
+ # Evaluates each token to clear any possible variables
524
+ results = []
525
+ for token in tokens:
526
+ results.append(self.evaluate_expression([token]))
527
+ return results
528
+
529
+ def handle_ask(self, tokens, line_no):
530
+ if len(tokens) != 3:
531
+ raise ValueError(f"Invalid ASK command format at line {line_no}")
532
+
533
+ variable = tokens[1]
534
+ question = tokens[2]
535
+ if (question.startswith('"') and question.endswith('"')) or (question.startswith("'") and question.endswith("'")):
536
+ # Remove quotes
537
+ question = question[1:-1]
538
+ answer = ask_string(question)
539
+ if answer is not None:
540
+ if answer[0] == '"' and answer[-1] == '"':
541
+ # Remove quotes and don't uppercase and don't eval
542
+ answer = answer[1:-1]
543
+ else:
544
+ # Tokenize answer
545
+ answer_tokens = self.cstol_tokenizer(answer)
546
+
547
+ # Evaluate answer
548
+ answer = self.evaluate_expression(answer_tokens)
549
+
550
+ # Uppercase
551
+ if isinstance(answer, str):
552
+ answer = answer.upper()
553
+ self.variables.local_variables[variable] = answer
554
+
555
+ def handle_check(self, tokens, line_no):
556
+ expressions = self.extract_expressions(tokens[1:], ",")
557
+ for expr in expressions:
558
+ format = None
559
+ if expr[0][0] == '%':
560
+ # Format string
561
+ # %X or %x Output in hexadecimal values Value is converted to an integer prior to applying the format
562
+ # %O or %o Output in octal values Value is converted to an integer prior to applying the format
563
+ # %B or %b Output in binary values Value is converted to an integer prior to applying the format
564
+ # %I or %i Output in decimal values Value is converted to an integer prior to applying the format
565
+ # %D or %d Output in decimal values Value is converted to an integer prior to applying the format
566
+ # %F or %f Output in floating point values Default for integer or raw value
567
+ # %E or %e Output in floating point values Default for float or EU value
568
+ format = expr[0][1:].upper()
569
+ if format not in ['X', 'O', 'B', 'I', 'D', 'F', 'E']:
570
+ raise ValueError(f"Invalid format %'{format}' in CHECK command at line {line_no}")
571
+ if len(expr) < 2:
572
+ raise ValueError(f"Missing value for format %'{format}' in CHECK command at line {line_no}")
573
+ # Remove format from the expression
574
+ expr = expr[1:]
575
+
576
+ # Check for VS
577
+ success = True
578
+ fail_message = None
579
+ source = None
580
+ value = None
581
+ vs_expressions = self.extract_expressions(expr, "VS")
582
+ if len(vs_expressions) > 1:
583
+ source = " ".join(vs_expressions[0])
584
+ value = self.evaluate_expression(vs_expressions[0])
585
+ # First split any non-timestamp tokens containing colons
586
+ vs_tokens = self.split_vs_tokens_on_colon(self.flatten(vs_expressions[1:]))
587
+ colon_expressions = self.extract_expressions(vs_tokens, ":")
588
+ if len(colon_expressions) > 1:
589
+ # Range check
590
+ if len(colon_expressions) != 2:
591
+ raise ValueError(f"Invalid range format at line {line_no}")
592
+ range_start = self.evaluate_expression(colon_expressions[0])
593
+ range_end = self.evaluate_expression(colon_expressions[1])
594
+ if range_start > range_end:
595
+ raise ValueError(f"Invalid VS range {range_start}:{range_end} at line {line_no}")
596
+ if (value < range_start) or (value > range_end):
597
+ success = False
598
+ fail_message = f"not in range {range_start}:{range_end}"
599
+ else:
600
+ # Single value check
601
+ vs_value = self.evaluate_expression(colon_expressions[0])
602
+ if value != vs_value:
603
+ success = False
604
+ fail_message = f"not equal to {vs_value}"
605
+ else:
606
+ # No VS - Just a print check
607
+ source = " ".join(expr)
608
+ value = self.evaluate_expression(expr)
609
+
610
+ formatted_value = None
611
+ if format is None:
612
+ formatted_value = str(value)
613
+ elif format in ['X', 'O', 'B', 'I', 'D']:
614
+ # Convert to integer
615
+ value = int(value)
616
+ if format == 'X':
617
+ formatted_value = (f"{value:X}")
618
+ elif format == 'O':
619
+ formatted_value = (f"{value:o}")
620
+ elif format == 'B':
621
+ formatted_value = (f"{value:b}")
622
+ elif format in ['I', 'D']:
623
+ formatted_value = str(value)
624
+ elif format in ['F', 'E']:
625
+ # Convert to float
626
+ value = float(value)
627
+ if format == 'F':
628
+ formatted_value = f"{value:.6f}"
629
+ elif format == 'E':
630
+ formatted_value = f"{value:.6e}"
631
+
632
+ if success:
633
+ print(f"CHECK SUCCESS: {source} = {formatted_value}")
634
+ else:
635
+ message = f"CHECK FAILED: {source} = {formatted_value} ({fail_message})"
636
+ print(message)
637
+ raise CheckError(message)
638
+
639
+ def handle_clear(self, tokens, line_no):
640
+ """ Only handles displays """
641
+ if tokens[1].upper() == 'ALL':
642
+ clear_all_screens()
643
+ else:
644
+ screen_name = tokens[1]
645
+ if (screen_name.startswith('"') and screen_name.endswith('"')) or (screen_name.startswith("'") and screen_name.endswith("'")):
646
+ # Remove quotes
647
+ screen_name = screen_name[1:-1]
648
+ clear_screen(*screen_name.split())
649
+
650
+ def handle_cmd(self, tokens, line_no):
651
+ if tokens[0].upper() == 'NOW':
652
+ # Drop NOW
653
+ tokens = tokens[1:]
654
+
655
+ verb = tokens[0].upper()
656
+ tokens = tokens[1:]
657
+ match verb:
658
+ case "ACTIVATE" | "ARM" | "BOOT" | "CHANGE" | "CLOSE" | "DISABLE" | "DISARM" | "DRIVE" | \
659
+ "DUMP" | "ENABLE" | "FIRE" | "FLYBACK" | "FORCE" | "GET" | "HALT" | "HOLD" | "IGNORE" | "INITIATE" | \
660
+ "MOVE" | "NOW" | "OPEN" | "PASS" | "PERFORM" | "RESET" | "SELECT" | "SET" | "SLEW" | "STEP" | "TEST" | \
661
+ "TOGGLE" | "TURN" | "USE":
662
+ # Handle all the weird verbs
663
+ if verb == "TURN" or verb == "FORCE":
664
+ # Expect ON or OFF to follow
665
+ if tokens[0].upper() == 'ON' or tokens[0].upper() == 'OFF':
666
+ verb = verb + tokens[0].upper()
667
+ tokens = tokens[1:]
668
+ else:
669
+ raise ValueError(f"TURN and FORCE must be followed by ON or OFF at line {line_no}")
670
+ case "CMD":
671
+ pass
672
+
673
+ # Now we need to discover any TO, BY, FROM, WITH clauses
674
+ target_name = None
675
+ cmd_name = None
676
+ args = {}
677
+ single_value_expressions = None
678
+ to_expressions = self.extract_expressions(tokens, "TO")
679
+ by_expressions = self.extract_expressions(tokens, "BY")
680
+ from_expressions = self.extract_expressions(tokens, "FROM")
681
+ with_expressions = self.extract_expressions(tokens, "WITH")
682
+ if len(to_expressions) > 1:
683
+ single_value_expressions = to_expressions
684
+ elif len(by_expressions) > 1:
685
+ single_value_expressions = by_expressions
686
+ elif len(from_expressions) > 1:
687
+ single_value_expressions = from_expressions
688
+
689
+ if single_value_expressions is not None:
690
+ # TO, BY, or FROM
691
+ value = self.evaluate_expression(single_value_expressions[1])
692
+ pretokens = self.evaluate_tokens(single_value_expressions[0])
693
+ if len(pretokens) == 1:
694
+ target_name = pretokens[0]
695
+ cmd_name = verb
696
+ args["VALUE"] = value
697
+ elif len(pretokens) == 2:
698
+ target_name = pretokens[0]
699
+ if verb == "CMD":
700
+ cmd_name = pretokens[1]
701
+ args["VALUE"] = value
702
+ else:
703
+ cmd_name = verb
704
+ args[pretokens[1]] = value
705
+ else:
706
+ raise ValueError(f"Malformed TO cmd at line {line_no}")
707
+ elif len(with_expressions) > 1:
708
+ # WITH
709
+ pretokens = self.evaluate_tokens(with_expressions[0])
710
+ target_name = pretokens[0]
711
+ if verb == "CMD":
712
+ cmd_name = pretokens[1]
713
+ else:
714
+ cmd_name = verb
715
+ expressions = self.extract_expressions(with_expressions[1])
716
+ for expr in expressions:
717
+ arg_name = self.evaluate_tokens([expr[0]])[0]
718
+ value = self.evaluate_expression(expr[1:])
719
+ args[arg_name] = value
720
+ else:
721
+ # No clauses
722
+ tokens = self.evaluate_tokens(tokens)
723
+ target_name = tokens[0]
724
+ if verb == "CMD":
725
+ cmd_name = tokens[1]
726
+ else:
727
+ cmd_name = verb
728
+
729
+ cmd(target_name, cmd_name, args)
730
+
731
+ def handle_declare(self, tokens, line_no):
732
+ """ We ignore ranges and allowed value lists """
733
+ # DECLARE mode variable-name = default-value [range value-list]
734
+ mode = tokens[1].upper()
735
+ if mode not in ['INPUT', 'VARIABLE', 'CONSTANT']:
736
+ raise ValueError(f"Invalid mode '{mode}' in DECLARE command at line {line_no}")
737
+ variable_name = tokens[2]
738
+ if not variable_name.startswith('$'):
739
+ raise ValueError(f"Variable name must start with '$' in DECLARE command at line {line_no}")
740
+ equals = tokens[3]
741
+ if equals != '=':
742
+ raise ValueError(f"Expected '=' after variable name in DECLARE command at line {line_no}")
743
+ #default_value = self.token_to_value(tokens[4])
744
+ default_value = self.evaluate_tokens([tokens[4]])[0]
745
+ self.variables.local_variables[variable_name] = default_value
746
+
747
+ def handle_display(self, tokens, line_no):
748
+ screen_name = tokens[1]
749
+ if (screen_name.startswith('"') and screen_name.endswith('"')) or (screen_name.startswith("'") and screen_name.endswith("'")):
750
+ # Remove quotes
751
+ screen_name = screen_name[1:-1]
752
+ display_screen(*screen_name.split())
753
+
754
+ def handle_else(self, tokens, lines, line_no):
755
+ if len(tokens) == 1 and tokens[0] == 'ELSE':
756
+ # The only way we ever hit an ELSE is if we were in a successful block beforehand
757
+ # Therefore goto the ENDIF
758
+ depth = 1
759
+ for i in range(line_no, len(lines)):
760
+ next_line = lines[i].strip().upper()
761
+ if next_line.startswith('IF '):
762
+ depth += 1
763
+ elif next_line.startswith('ENDIF') or next_line.startswith('END IF'):
764
+ depth -= 1
765
+ if depth == 0:
766
+ return i + 1
767
+ raise ValueError(f"No matching ENDIF for ELSE command at line {line_no}")
768
+
769
+ elif len(tokens) > 1:
770
+ if tokens[0] == 'ELSEIF':
771
+ return self.handle_if(tokens, lines, line_no)
772
+ elif (tokens[0] == 'ELSE' and tokens[1] == 'IF'):
773
+ return self.handle_if(tokens[1:], lines, line_no)
774
+
775
+ def handle_end(self, tokens, line_no):
776
+ if len(tokens) == 1 and tokens[0] == 'END':
777
+ raise ValueError(f"Unexpected END command at line {line_no}, expected ENDIF, ENDLOOP, ENDMACRO, or ENDPROC")
778
+ elif (len(tokens) == 1 and tokens[0] == 'ENDIF') or (tokens[0] == 'END' and tokens[1] == 'IF'):
779
+ pass
780
+ elif (len(tokens) == 1 and tokens[0] == 'ENDLOOP') or (tokens[0] == 'END' and tokens[1] == 'LOOP'):
781
+ if len(self.variables.loop_stack) > 0:
782
+ loop_info = self.variables.loop_stack[-1]
783
+ loop_info[2] += 1
784
+ if loop_info[1] is not None:
785
+ # Counted loop, decrement the count
786
+ loop_info[1] -= 1
787
+ if loop_info[1] > 0:
788
+ return loop_info[0]
789
+ else:
790
+ self.variables.loop_stack.pop()
791
+ return line_no + 1 # Continue to the next line
792
+ else:
793
+ # Infinite loop, just continue to the start of the loop
794
+ return loop_info[0]
795
+ return line_no + 1
796
+
797
+ def handle_escape(self, _tokens, lines, line_no):
798
+ # Find the matching ENDLOOP
799
+ depth = 1
800
+ for i in range(line_no, len(lines)):
801
+ next_line = lines[i].strip()
802
+ if next_line.startswith('LOOP'):
803
+ depth += 1
804
+ elif next_line.startswith('END LOOP') or next_line.startswith('ENDLOOP'):
805
+ depth -= 1
806
+ if depth == 0:
807
+ if len(self.variables.loop_stack) > 0:
808
+ self.variables.loop_stack.pop()
809
+ return i + 1
810
+ raise ValueError(f"No matching ENDLOOP found for ESCAPE command at line {line_no}")
811
+
812
+ def handle_go(self, tokens, lines, line_no):
813
+ if tokens[0] == 'GOTO' or (len(tokens) > 1 and tokens[0] == 'GO' and tokens[1] == 'TO'):
814
+ if (tokens[0] == 'GOTO' and len(tokens) < 2) or (tokens[0] == 'GO' and tokens[1] != 'TO' and len(tokens) < 3):
815
+ raise ValueError(f"Invalid GOTO command format at line {line_no}")
816
+ label = None
817
+ if tokens[0] == 'GOTO':
818
+ label = tokens[1] + ':'
819
+ else:
820
+ label = tokens[2] + ':'
821
+ # Find the label in the lines
822
+ for i in range(1, len(lines)):
823
+ next_line = lines[i - 1].strip()
824
+ if next_line.startswith(label):
825
+ return i
826
+ raise ValueError(f"Label '{label}' not found in GOTO command at line {line_no}")
827
+
828
+ def handle_if(self, tokens, lines, line_no):
829
+ expression_tokens = tokens[1:]
830
+ result = None
831
+ try:
832
+ # Evaluate the expression and store the result in the variable
833
+ result = self.evaluate_expression(expression_tokens)
834
+ except Exception as e:
835
+ raise ValueError(f"Error evaluating expression '{expression_tokens}' in IF command at line {line_no}: {e}")
836
+
837
+ if result:
838
+ return line_no + 1 # Continue to the next line if the condition is true
839
+ # If the condition is false, skip to the next ENDIF or ELSE
840
+ else:
841
+ # Find the matching ENDIF or ELSE
842
+ depth = 1
843
+ for i in range(line_no, len(lines)):
844
+ next_line = lines[i].strip().upper()
845
+ if next_line.startswith('IF '):
846
+ depth += 1
847
+ elif next_line.startswith('ENDIF') or next_line.startswith('END IF'):
848
+ depth -= 1
849
+ if depth == 0:
850
+ return i + 1
851
+ elif next_line.startswith('ELSE'):
852
+ if next_line.startswith('ELSEIF') or next_line.startswith('ELSE IF'):
853
+ # Continue to the ELSE IF
854
+ if depth == 1:
855
+ return i + 1
856
+ else:
857
+ # Regular ELSE - Continue to line after
858
+ if depth == 1:
859
+ return i + 2
860
+
861
+ raise ValueError(f"No matching ENDIF or ELSE found for IF command at line {line_no}")
862
+
863
+ def handle_let(self, tokens, line_no):
864
+ # LET variable = expression
865
+ if len(tokens) < 4:
866
+ raise ValueError(f"Invalid LET command format at line {line_no}")
867
+ variable_name = tokens[1]
868
+ item_name = None
869
+ expression_tokens = None
870
+ if not variable_name.startswith('$'):
871
+ if tokens[2] != '=':
872
+ # Global variable for set_tlm
873
+ item_name = tokens[2]
874
+ expression_tokens = tokens[4:]
875
+ else:
876
+ raise ValueError(f"Non-global variable name must start with '$' in LET command at line {line_no}")
877
+ elif tokens[2] == '=':
878
+ # Local or Special variable
879
+ expression_tokens = tokens[3:]
880
+ else:
881
+ raise ValueError(f"Expected '=' after variable name in LET command at line {line_no}")
882
+
883
+ result = None
884
+ try:
885
+ # Evaluate the expression and store the result in the variable
886
+ result = self.evaluate_expression(expression_tokens)
887
+ except Exception as e:
888
+ raise ValueError(f"Error evaluating expression '{expression_tokens}' in LET command at line {line_no}: {e}")
889
+
890
+ if item_name:
891
+ if isinstance(result, str):
892
+ set_tlm(f"{variable_name} LATEST {item_name} = '{result}'")
893
+ else:
894
+ set_tlm(f"{variable_name} LATEST {item_name} = {result}")
895
+ elif variable_name.startswith('$$'):
896
+ # Special variable
897
+ self.variables.set_special_variable(variable_name, result)
898
+ else:
899
+ # Local variable
900
+ self.variables.local_variables[variable_name] = result
901
+
902
+ def handle_load(self, tokens, line_no):
903
+ # LOAD external-element-name AT location FROM file-name
904
+ expressions = self.extract_expressions(tokens[1:], "AT")
905
+ if len(expressions) != 2:
906
+ raise ValueError(f"Invalid LOAD command format at line {line_no}")
907
+ interface_name = expressions[0]
908
+ expressions = self.extract_expressions(self.flatten(expressions[1:]), "FROM")
909
+ if len(expressions) != 2:
910
+ raise ValueError(f"Invalid LOAD command format at line {line_no}")
911
+ location = expressions[0]
912
+ filename = expressions[1]
913
+ results = self.evaluate_expressions([interface_name, location, filename])
914
+ interface_name = results[0]
915
+ location = results[1]
916
+ filename = results[2]
917
+ file = get_target_file(filename)
918
+ data = file.read()
919
+ file.close()
920
+ send_raw(interface_name, data)
921
+
922
+ def handle_loop(self, tokens, line_no):
923
+ if len(tokens) == 1:
924
+ # Infinite loop
925
+ self.variables.loop_stack.append([line_no + 1, None, 1])
926
+ return line_no + 1 # Continue to the next line
927
+ elif len(tokens) == 2:
928
+ # Counted loop
929
+ count = int(tokens[1])
930
+ self.variables.loop_stack.append([line_no + 1, count, 1])
931
+ return line_no + 1 # Continue to the next line
932
+ else:
933
+ raise ValueError(f"Invalid LOOP command format at line {line_no}")
934
+
935
+ def handle_proc(self, tokens, line_no):
936
+ # tokens[1] is proc name
937
+ if len(tokens) > 2:
938
+ # Variables
939
+ index = 0
940
+ for token in tokens[2:]:
941
+ if token == ',':
942
+ continue
943
+ elif token.startswith('$'):
944
+ self.variables.local_variables[token] = os.getenv(f"CSTOL_ARG_{index}")
945
+ index += 1
946
+ else:
947
+ raise ValueError(f"Invalid variable '{token}' in PROC command at line {line_no}")
948
+
949
+ def handle_return(self, tokens, lines, line_no):
950
+ if len(tokens) > 1:
951
+ if tokens[1].upper() == 'ALL':
952
+ raise StopScript
953
+ return (len(lines) + 1)
954
+
955
+ def handle_run(self, tokens, line_no):
956
+ if len(tokens) < 2:
957
+ raise ValueError(f"Invalid RUN command format at line {line_no}")
958
+ script = tokens[1]
959
+ if len(tokens) > 2:
960
+ expressions = self.extract_expressions(tokens[2:], ",")
961
+ results = self.evaluate_expressions(expressions)
962
+ string_results = [('"' + str(result) + '"') for result in results]
963
+ string_results.insert(0, script)
964
+ script = ' '.join(string_results)
965
+ print(f"Running system call: {script}")
966
+ os.system(script)
967
+
968
+ def handle_send(self, tokens, line_no):
969
+ # SEND bit-pattern-command TO external-element-name
970
+ # COSMOS implementation uses interface name instead of external-element-name
971
+ expressions = self.extract_expressions(tokens[1:], "TO")
972
+ if len(expressions) != 2:
973
+ raise ValueError(f"Invalid SEND command format at line {line_no}")
974
+ results = self.evaluate_expressions(expressions)
975
+ data = results[0]
976
+ interface_name = results[1]
977
+ send_raw(interface_name, data)
978
+
979
+ def handle_start(self, tokens, line_no):
980
+ # START proc-name [argument-list]
981
+ proc_name = tokens[1]
982
+
983
+ if (proc_name.startswith('"') and proc_name.endswith('"')) or (proc_name.startswith("'") and proc_name.endswith("'")):
984
+ # Remove quotes
985
+ proc_name = proc_name[1:-1]
986
+
987
+ args = []
988
+ if len(tokens) > 2:
989
+ # Handle argument list
990
+ expressions = self.extract_expressions(tokens[2:], ",")
991
+ results = self.evaluate_expressions(expressions)
992
+ args = [str(result) for result in results]
993
+ for index, arg in enumerate(args):
994
+ # Set the environment variable for the argument
995
+ os.environ[f"CSTOL_ARG_{index}"] = arg
996
+ start(proc_name, bind_variables=False)
997
+
998
+ def handle_switch(self, tokens, line_no):
999
+ action = tokens[1].upper()
1000
+ if action not in ['ON', 'OFF']:
1001
+ raise ValueError(f"Invalid SWITCH action '{action}' at line {line_no}")
1002
+ interface_name = tokens[2]
1003
+ if action == 'ON':
1004
+ connect_interface(interface_name)
1005
+ elif action == 'OFF':
1006
+ disconnect_interface(interface_name)
1007
+
1008
+ def handle_wait(self, tokens, line_no):
1009
+ if len(tokens) == 1:
1010
+ # Indefinite wait
1011
+ wait()
1012
+ elif len(tokens) == 2:
1013
+ # Timestamp wait - Still need to handle a possible expression like a variable
1014
+ seconds = self.evaluate_expression([tokens[1]])
1015
+ if isinstance(seconds, (int, float, complex)) and not isinstance(seconds, bool):
1016
+ if seconds > self.ONE_YEAR_SECONDS:
1017
+ now = datetime.datetime.now().timestamp()
1018
+ seconds = seconds - now
1019
+ if seconds < 0:
1020
+ seconds = 0.0
1021
+ wait(seconds)
1022
+ else:
1023
+ raise ValueError(f"Invalid timestamp format at line {line_no}")
1024
+ else:
1025
+ # Conditional expression wait
1026
+ expressions = self.extract_expressions(tokens[1:], seperator="OR")
1027
+ python_expression = self.build_python_expression(expressions[0])
1028
+ if len(expressions) > 1:
1029
+ # Timeout given with "OR FOR" or "OR UNTIL"
1030
+ seconds = self.evaluate_expression(expressions[1][1:]) # Drop assumed FOR or UNTIL token
1031
+ if isinstance(seconds, (int, float, complex)) and not isinstance(seconds, bool):
1032
+ if seconds > self.ONE_YEAR_SECONDS:
1033
+ now = datetime.datetime.now().timestamp()
1034
+ seconds = seconds - now
1035
+ if seconds < 0:
1036
+ seconds = 0.0
1037
+ result = wait_expression(python_expression, seconds, self.variables.get_special_variable("$$CHECK_INTERVAL"), globals={'math': math, 'os': os, 'tlm': tlm})
1038
+ if result:
1039
+ self.variables.set_special_variable("$$ERROR", "NO_ERROR")
1040
+ else:
1041
+ self.variables.set_special_variable("$$ERROR", "TIME_OUT")
1042
+ else:
1043
+ raise ValueError(f"Invalid timestamp format at line {line_no}")
1044
+ else:
1045
+ result = wait_expression(python_expression, 1000000000, self.variables.get_special_variable("$$CHECK_INTERVAL"), globals={'math': math, 'os': os, 'tlm': tlm}) # Effective infinite wait
1046
+ if result:
1047
+ self.variables.set_special_variable("$$ERROR", "NO_ERROR")
1048
+ else:
1049
+ self.variables.set_special_variable("$$ERROR", "TIME_OUT")
1050
+
1051
+ def handle_write(self, tokens, line_no):
1052
+ expressions = self.extract_expressions(tokens[1:], ",")
1053
+ results = []
1054
+ for expr in expressions:
1055
+ result = ''
1056
+ if expr[0][0] == '%':
1057
+ # Format string
1058
+ # %X or %x Output in hexadecimal values Value is converted to an integer prior to applying the format
1059
+ # %O or %o Output in octal values Value is converted to an integer prior to applying the format
1060
+ # %B or %b Output in binary values Value is converted to an integer prior to applying the format
1061
+ # %I or %i Output in decimal values Value is converted to an integer prior to applying the format
1062
+ # %D or %d Output in decimal values Value is converted to an integer prior to applying the format
1063
+ # %F or %f Output in floating point values Default for integer or raw value
1064
+ # %E or %e Output in floating point values Default for float or EU value
1065
+ format = expr[0][1:].upper()
1066
+ if format not in ['X', 'O', 'B', 'I', 'D', 'F', 'E']:
1067
+ raise ValueError(f"Invalid format %'{format}' in WRITE command at line {line_no}")
1068
+ if len(expr) < 2:
1069
+ raise ValueError(f"Missing value for format %'{format}' in WRITE command at line {line_no}")
1070
+ value = self.evaluate_expression(expr[1:])
1071
+ if format in ['X', 'O', 'B', 'I', 'D']:
1072
+ # Convert to integer
1073
+ value = int(value)
1074
+ if format == 'X':
1075
+ result = (f"{value:X}")
1076
+ elif format == 'O':
1077
+ result = (f"{value:o}")
1078
+ elif format == 'B':
1079
+ result = (f"{value:b}")
1080
+ elif format in ['I', 'D']:
1081
+ result = str(value)
1082
+ elif format in ['F', 'E']:
1083
+ # Convert to float
1084
+ value = float(value)
1085
+ if format == 'F':
1086
+ result = f"{value:.6f}"
1087
+ elif format == 'E':
1088
+ result = f"{value:.6e}"
1089
+ else:
1090
+ result = str(self.evaluate_expression(expr))
1091
+ results.append(result)
1092
+ # Join the results with spaces
1093
+ output = ' '.join(results)
1094
+ print(output)
1095
+
1096
+ def run_text(self, text, filename = None, line_no = 1, end_line_no = None, bind_variables = False):
1097
+ saved_variables = self.variables
1098
+ try:
1099
+ if not bind_variables:
1100
+ self.variables = CstolVariables()
1101
+ super().run_text(text, filename, line_no, end_line_no, bind_variables)
1102
+ finally:
1103
+ self.variables = saved_variables
1104
+
1105
+ # Override this method in the subclass to implement the script engine
1106
+ def run_line(self, line, lines, filename, line_no):
1107
+ tokens = self.cstol_tokenizer(line)
1108
+
1109
+ # Skip completely blank lines
1110
+ if len(tokens) == 0:
1111
+ return line_no + 1
1112
+
1113
+ # Handle continuation lines
1114
+ if self.saved_tokens:
1115
+ tokens = self.saved_tokens + tokens
1116
+ self.saved_tokens = None
1117
+ if tokens[-1] == '&':
1118
+ self.saved_tokens = tokens[:-1] # Everything before the '&' is saved for the next line
1119
+ return line_no + 1 # Skip to the next line
1120
+
1121
+ # Remove any trailing comments
1122
+ if ';' in tokens:
1123
+ comment_index = tokens.index(';')
1124
+ tokens = tokens[:comment_index]
1125
+
1126
+ if len(tokens) != 0:
1127
+ keyword = tokens[0].upper()
1128
+
1129
+ # Handle labels
1130
+ if keyword.endswith(':'):
1131
+ if len(tokens) > 1:
1132
+ tokens = tokens[1:]
1133
+ keyword = tokens[0].upper()
1134
+ else:
1135
+ return line_no + 1
1136
+
1137
+ match keyword:
1138
+ case "ASK":
1139
+ self.handle_ask(tokens, line_no)
1140
+ case "BEGIN":
1141
+ pass
1142
+ case "CANCEL" | "CHECKPOINT" | "COMMIT" | "COMPILE" | "CSTOL" | "DECOMPILE" | "DEFINE" | "DELETE" | \
1143
+ "FLUSH" | "INSERT" | "LOCK" | "MACRO" | "RECORD" | "REPORT" | "RESTORE" | "RETREIVE" | "RETRY" | \
1144
+ "SHOW" | "SNAP" | "STOP" | "UNDEFINE" | "UNLOCK" | "UPDATE" | "ROUTE":
1145
+ # These keywords are noops for this CSTOL script engine
1146
+ print(f"Ignoring Unsupported Keyword: {keyword}")
1147
+ case "CHECK":
1148
+ self.handle_check(tokens, line_no)
1149
+ case "CLEAR":
1150
+ self.handle_clear(tokens, line_no)
1151
+ case "ACTIVATE" | "ARM" | "BOOT" | "CHANGE" | "CLOSE" | "CMD" | "DISABLE" | "DISARM" | "DRIVE" | \
1152
+ "DUMP" | "ENABLE" | "FIRE" | "FLYBACK" | "FORCE" | "GET" | "HALT" | "HOLD" | "IGNORE" | "INITIATE" | \
1153
+ "MOVE" | "NOW" | "OPEN" | "PASS" | "PERFORM" | "RESET" | "SELECT" | "SET" | "SLEW" | "STEP" | "TEST" | \
1154
+ "TOGGLE" | "TURN" | "USE":
1155
+ self.handle_cmd(tokens, line_no)
1156
+ case "DECLARE":
1157
+ self.handle_declare(tokens, line_no)
1158
+ case "DISPLAY":
1159
+ self.handle_display(tokens, line_no)
1160
+ case "ELSE" | "ELSEIF":
1161
+ return self.handle_else(tokens, lines, line_no)
1162
+ case "END" | "ENDIF" | "ENDLOOP" | "ENDMACRO" | "ENDPROC":
1163
+ return self.handle_end(tokens, line_no)
1164
+ case "ESCAPE":
1165
+ return self.handle_escape(tokens, lines, line_no)
1166
+ case "GO" | "GOTO":
1167
+ return self.handle_go(tokens, lines, line_no)
1168
+ case "IF":
1169
+ return self.handle_if(tokens, lines, line_no)
1170
+ case "LET":
1171
+ self.handle_let(tokens, line_no)
1172
+ case "LOAD":
1173
+ self.handle_load(tokens, line_no)
1174
+ case "LOOP":
1175
+ self.handle_loop(tokens, line_no)
1176
+ case "PROC":
1177
+ self.handle_proc(tokens, line_no)
1178
+ case "RETURN":
1179
+ return self.handle_return(tokens, lines, line_no)
1180
+ case "RUN":
1181
+ self.handle_run(tokens, line_no)
1182
+ case "SEND":
1183
+ self.handle_send(tokens, line_no)
1184
+ case "START":
1185
+ self.handle_start(tokens, line_no)
1186
+ case "SWITCH":
1187
+ self.handle_switch(tokens, line_no)
1188
+ case "WAIT":
1189
+ self.handle_wait(tokens, line_no)
1190
+ case "WRITE":
1191
+ self.handle_write(tokens, line_no)
1192
+ case _:
1193
+ raise ValueError(f"Unknown keyword '{keyword}' at line {line_no}")
1194
+
1195
+ return line_no + 1