openc3-cosmos-script-engine-cstol 1.0.0 → 1.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f1bc3e1c0f55e2a66fc5df1c1b156ef06f29a2b98132f1a9ca89d26562ba1fb
4
- data.tar.gz: da77cd0df1e9908ca50654fb5b9a9cbad3b0359353e9009daab56504bab811d1
3
+ metadata.gz: 9d0e4ae051329106a645f8cfafc4692be99ada07d577dc4bdef50b5f8a017c26
4
+ data.tar.gz: 4a8728bf65623019a3f525ed1bcb7527865d927fcbcf1a8b14dd62d1e63f2bb2
5
5
  SHA512:
6
- metadata.gz: b70bb6b13b4fac767e2bfb3407318c350029c94ba2535f7c4a33a615c2b4deb5491ebcf6f6fd2502f33a8be75d6bbf41a6a495602eb19e9f0d082e6261f48791
7
- data.tar.gz: 2d5ba36b266566e4eda8e6cabe4ed67589411335d3b6c3d2545ab4fb28162ec0b64c7d544ec4b1a8fb2910ae36aa4dd5b80fd0e09263c12808b346dce4d0c9ed
6
+ metadata.gz: e992d06108e1e8c401cb3be499f3fac0da9e3f4a0b94139099a486ffbe7906be2b508654ab4dab7c9a4f2c7dc40f1f8794ce0ca84be4c7bd9648ac3c5227fc8a
7
+ data.tar.gz: d4688f23af1f74ba8969981105cb841cf0064e175e48825f33a460a54ed6559c84f80c4e1cb1209fd8aa147ef60a0ede414b3d8367c138de6b55adf0333d9071
@@ -25,12 +25,18 @@ import shlex
25
25
  import datetime
26
26
  import math
27
27
  import os
28
- from openc3.script.exceptions import CheckError, StopScript
28
+ from openc3.script.exceptions import CheckError, StopScriptError
29
29
  from openc3.script_engines.script_engine import ScriptEngine
30
30
  from openc3.script import wait, wait_expression, ask_string, clear_screen, clear_all_screens, set_tlm, display_screen, \
31
31
  connect_interface, disconnect_interface, send_raw, start, cmd, get_target_file, tlm, ask_string, set_line_delay, step_mode, run_mode
32
32
 
33
33
  class CstolVariables:
34
+ # NOTE: special_variables is INTENTIONALLY a class-level (shared) dictionary.
35
+ # Special variables ($$ variables) are global to the engine and must persist
36
+ # across every script run and every CstolVariables instance. Unlike
37
+ # local_variables (which __init__ resets per instance), this state is meant to
38
+ # be shared by all instances. Do NOT move this into __init__ or copy it per
39
+ # instance - that would break the intended global behavior.
34
40
  special_variables = {
35
41
  "$$OWLT": 0.0,
36
42
  "$$ERROR": "NO_ERROR",
@@ -43,8 +49,23 @@ class CstolVariables:
43
49
 
44
50
  def __init__(self):
45
51
  self.local_variables = {}
52
+ self.if_stack = []
46
53
  self.loop_stack = []
47
54
 
55
+ def set_local_variable(self, name, value):
56
+ """
57
+ Sets a local variable. Variable names are case insensitive, so they are
58
+ normalized to upper case before being stored.
59
+ """
60
+ self.local_variables[name.upper()] = value
61
+
62
+ def get_local_variable(self, name):
63
+ """
64
+ Gets a local variable by name (case insensitive).
65
+ Returns None if the variable does not exist.
66
+ """
67
+ return self.local_variables.get(name.upper(), None)
68
+
48
69
  def set_special_variable(self, name, value):
49
70
  """
50
71
  Sets a special variable, which is a variable that starts with '$$'.
@@ -52,8 +73,9 @@ class CstolVariables:
52
73
  """
53
74
  if not name.startswith('$$'):
54
75
  raise ValueError(f"Special variable names must start with '$$': {name}")
76
+ name = name.upper()
55
77
  self.special_variables[name] = value
56
- match name.upper():
78
+ match name:
57
79
  case "$$CLP_STP_INTERVAL":
58
80
  set_line_delay(value)
59
81
  self.special_variables["$$STEP_INTERVAL"] = value
@@ -82,7 +104,8 @@ class CstolVariables:
82
104
  """
83
105
  if not name.startswith('$$'):
84
106
  raise ValueError(f"Special variable names must start with '$$': {name}")
85
- match name.upper():
107
+ name = name.upper()
108
+ match name:
86
109
  case "$$CURRENT_TIME":
87
110
  return datetime.datetime.now(datetime.timezone.utc).timestamp()
88
111
  case "$$SC_TIME":
@@ -240,7 +263,19 @@ class CstolScriptEngine(ScriptEngine):
240
263
  i += 3 # Skip the next 2 tokens since we combined them
241
264
 
242
265
  else:
243
- reconstructed_tokens.append(tokens[i])
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)
244
279
  i += 1
245
280
 
246
281
  return reconstructed_tokens
@@ -280,7 +315,7 @@ class CstolScriptEngine(ScriptEngine):
280
315
  # Handle local variables
281
316
  if token.startswith('$'):
282
317
  # Get the actual value of local variable
283
- value = self.variables.local_variables.get(token, None)
318
+ value = self.variables.get_local_variable(token)
284
319
  if value is None:
285
320
  raise ValueError(f"Unknown variable: {token}")
286
321
  if isinstance(value, str):
@@ -312,19 +347,19 @@ class CstolScriptEngine(ScriptEngine):
312
347
  continue
313
348
 
314
349
  # Handle radix notation integers
315
- matches = re.match(r'^[BODXHbodxh]#\d+$', token)
350
+ # Allow hex digits (A-F) for all bases so X#FF and H#DEADBEEF parse;
351
+ # int() with the proper base validates the digits for each radix.
352
+ matches = re.match(r'^([BODXHbodxh])#([0-9A-Fa-f]+)$', token)
316
353
  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:]}')
354
+ radix_char = matches.group(1).upper()
355
+ digits = matches.group(2)
356
+ match radix_char:
324
357
  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:]}')
358
+ # Hex byte buffer (raw bytes rather than an integer)
359
+ final_tokens.append(str(int(digits, 16).to_bytes((len(digits) + 1) // 2, 'big')))
360
+ case _:
361
+ base = {'B': 2, 'O': 8, 'D': 10, 'X': 16}[radix_char]
362
+ final_tokens.append(str(int(digits, base)))
328
363
  previous_number = True
329
364
  continue
330
365
 
@@ -359,7 +394,7 @@ class CstolScriptEngine(ScriptEngine):
359
394
  token = final_tokens[i]
360
395
 
361
396
  # Check for "RAW" pattern first: "RAW", "TARGET_NAME", "ITEM_NAME"
362
- if (token == '"RAW"' and i + 2 < len(final_tokens) and
397
+ if (token.upper() == '"RAW"' and i + 2 < len(final_tokens) and
363
398
  final_tokens[i + 1].startswith('"') and final_tokens[i + 1].endswith('"') and
364
399
  final_tokens[i + 2].startswith('"') and final_tokens[i + 2].endswith('"')):
365
400
 
@@ -388,7 +423,7 @@ class CstolScriptEngine(ScriptEngine):
388
423
  return ' '.join(processed_tokens)
389
424
 
390
425
  # 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*)'
426
+ TIMESTAMP_PATTERN = r'(?:(\d{4})?/(\d{1,3})?-)?(\d{0,2}):(\d{0,2}):(\d{1,2}\.?\d*)'
392
427
 
393
428
  # Will convert a CSTOL Clock Time or Delta Time into floating point seconds
394
429
  def parse_timestamp(self, timestamp, now = None):
@@ -398,6 +433,10 @@ class CstolScriptEngine(ScriptEngine):
398
433
  matches = re.match(self.TIMESTAMP_PATTERN, timestamp)
399
434
  if matches:
400
435
  year, day_of_year, hour, minute, second = matches.groups()
436
+ if len(hour) == 0:
437
+ hour = 0
438
+ if len(minute) == 0:
439
+ minute = 0
401
440
  result = {
402
441
  'hour': int(hour),
403
442
  'minute': int(minute),
@@ -537,9 +576,12 @@ class CstolScriptEngine(ScriptEngine):
537
576
  question = question[1:-1]
538
577
  answer = ask_string(question)
539
578
  if answer is not None:
540
- if answer[0] == '"' and answer[-1] == '"':
579
+ if len(answer) >= 2 and answer[0] == '"' and answer[-1] == '"':
541
580
  # Remove quotes and don't uppercase and don't eval
542
581
  answer = answer[1:-1]
582
+ elif answer.strip() == '':
583
+ # Empty answer - store as-is without tokenizing/evaluating
584
+ pass
543
585
  else:
544
586
  # Tokenize answer
545
587
  answer_tokens = self.cstol_tokenizer(answer)
@@ -550,11 +592,13 @@ class CstolScriptEngine(ScriptEngine):
550
592
  # Uppercase
551
593
  if isinstance(answer, str):
552
594
  answer = answer.upper()
553
- self.variables.local_variables[variable] = answer
595
+ self.variables.set_local_variable(variable, answer)
554
596
 
555
597
  def handle_check(self, tokens, line_no):
556
598
  expressions = self.extract_expressions(tokens[1:], ",")
557
599
  for expr in expressions:
600
+ if len(expr) == 0:
601
+ raise ValueError(f"Empty expression in CHECK command at line {line_no}")
558
602
  format = None
559
603
  if expr[0][0] == '%':
560
604
  # Format string
@@ -742,7 +786,7 @@ class CstolScriptEngine(ScriptEngine):
742
786
  raise ValueError(f"Expected '=' after variable name in DECLARE command at line {line_no}")
743
787
  #default_value = self.token_to_value(tokens[4])
744
788
  default_value = self.evaluate_tokens([tokens[4]])[0]
745
- self.variables.local_variables[variable_name] = default_value
789
+ self.variables.set_local_variable(variable_name, default_value)
746
790
 
747
791
  def handle_display(self, tokens, line_no):
748
792
  screen_name = tokens[1]
@@ -752,9 +796,29 @@ class CstolScriptEngine(ScriptEngine):
752
796
  display_screen(*screen_name.split())
753
797
 
754
798
  def handle_else(self, tokens, lines, line_no):
755
- if len(tokens) == 1 and tokens[0] == 'ELSE':
799
+ goto_endif = False
800
+ if len(tokens) > 1:
801
+ # ELSE IF is tricky - We can hit these after a successful IF, or an unsuccessful IF
802
+ # The if_stack keeps track if an earlier if was successful and its value will determine if we
803
+ # execute the ELSEIF or just goto the next ENDIF
804
+ current_if = False
805
+ if len(self.variables.if_stack) > 0:
806
+ current_if = self.variables.if_stack[-1]
807
+ if tokens[0].upper() == 'ELSEIF':
808
+ if current_if:
809
+ goto_endif = True
810
+ else:
811
+ return self.handle_if(tokens, lines, line_no)
812
+ elif (tokens[0].upper() == 'ELSE' and tokens[1].upper() == 'IF'):
813
+ if current_if:
814
+ goto_endif = True
815
+ else:
816
+ return self.handle_if(tokens[1:], lines, line_no)
817
+
818
+ if goto_endif or (len(tokens) == 1 and tokens[0].upper() == 'ELSE'):
756
819
  # The only way we ever hit an ELSE is if we were in a successful block beforehand
757
820
  # Therefore goto the ENDIF
821
+ self.variables.if_stack[-1] = True
758
822
  depth = 1
759
823
  for i in range(line_no, len(lines)):
760
824
  next_line = lines[i].strip().upper()
@@ -766,18 +830,16 @@ class CstolScriptEngine(ScriptEngine):
766
830
  return i + 1
767
831
  raise ValueError(f"No matching ENDIF for ELSE command at line {line_no}")
768
832
 
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)
833
+ raise ValueError(f"handle_else called with unexpected tokens at line {line_no}")
774
834
 
775
835
  def handle_end(self, tokens, line_no):
776
- if len(tokens) == 1 and tokens[0] == 'END':
836
+ if len(tokens) == 1 and tokens[0].upper() == 'END':
777
837
  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'):
838
+ elif (len(tokens) == 1 and tokens[0].upper() == 'ENDIF') or (tokens[0].upper() == 'END' and tokens[1].upper() == 'IF'):
839
+ # END IF pops the IF stack
840
+ self.variables.if_stack.pop()
779
841
  pass
780
- elif (len(tokens) == 1 and tokens[0] == 'ENDLOOP') or (tokens[0] == 'END' and tokens[1] == 'LOOP'):
842
+ elif (len(tokens) == 1 and tokens[0].upper() == 'ENDLOOP') or (tokens[0].upper() == 'END' and tokens[1].upper() == 'LOOP'):
781
843
  if len(self.variables.loop_stack) > 0:
782
844
  loop_info = self.variables.loop_stack[-1]
783
845
  loop_info[2] += 1
@@ -798,10 +860,14 @@ class CstolScriptEngine(ScriptEngine):
798
860
  # Find the matching ENDLOOP
799
861
  depth = 1
800
862
  for i in range(line_no, len(lines)):
801
- next_line = lines[i].strip()
802
- if next_line.startswith('LOOP'):
863
+ # Match on whole words so labels like "LOOPBACK:" are not mistaken
864
+ # for a nested LOOP keyword
865
+ words = lines[i].strip().upper().split()
866
+ if not words:
867
+ continue
868
+ if words[0] == 'LOOP':
803
869
  depth += 1
804
- elif next_line.startswith('END LOOP') or next_line.startswith('ENDLOOP'):
870
+ elif words[0] == 'ENDLOOP' or (words[0] == 'END' and len(words) > 1 and words[1] == 'LOOP'):
805
871
  depth -= 1
806
872
  if depth == 0:
807
873
  if len(self.variables.loop_stack) > 0:
@@ -810,17 +876,17 @@ class CstolScriptEngine(ScriptEngine):
810
876
  raise ValueError(f"No matching ENDLOOP found for ESCAPE command at line {line_no}")
811
877
 
812
878
  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):
879
+ if tokens[0].upper() == 'GOTO' or (len(tokens) > 1 and tokens[0].upper() == 'GO' and tokens[1].upper() == 'TO'):
880
+ if (tokens[0].upper() == 'GOTO' and len(tokens) < 2) or (tokens[0].upper() == 'GO' and len(tokens) < 3):
815
881
  raise ValueError(f"Invalid GOTO command format at line {line_no}")
816
882
  label = None
817
- if tokens[0] == 'GOTO':
818
- label = tokens[1] + ':'
883
+ if tokens[0].upper() == 'GOTO':
884
+ label = (tokens[1] + ':').upper()
819
885
  else:
820
- label = tokens[2] + ':'
886
+ label = (tokens[2] + ':').upper()
821
887
  # Find the label in the lines
822
888
  for i in range(1, len(lines)):
823
- next_line = lines[i - 1].strip()
889
+ next_line = lines[i - 1].strip().upper()
824
890
  if next_line.startswith(label):
825
891
  return i
826
892
  raise ValueError(f"Label '{label}' not found in GOTO command at line {line_no}")
@@ -835,6 +901,8 @@ class CstolScriptEngine(ScriptEngine):
835
901
  raise ValueError(f"Error evaluating expression '{expression_tokens}' in IF command at line {line_no}: {e}")
836
902
 
837
903
  if result:
904
+ # Mark if as handled
905
+ self.variables.if_stack[-1] = True
838
906
  return line_no + 1 # Continue to the next line if the condition is true
839
907
  # If the condition is false, skip to the next ENDIF or ELSE
840
908
  else:
@@ -897,7 +965,7 @@ class CstolScriptEngine(ScriptEngine):
897
965
  self.variables.set_special_variable(variable_name, result)
898
966
  else:
899
967
  # Local variable
900
- self.variables.local_variables[variable_name] = result
968
+ self.variables.set_local_variable(variable_name, result)
901
969
 
902
970
  def handle_load(self, tokens, line_no):
903
971
  # LOAD external-element-name AT location FROM file-name
@@ -923,14 +991,11 @@ class CstolScriptEngine(ScriptEngine):
923
991
  if len(tokens) == 1:
924
992
  # Infinite loop
925
993
  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
994
  else:
933
- raise ValueError(f"Invalid LOOP command format at line {line_no}")
995
+ # Counted loop - the count may be a literal, variable, or expression
996
+ count = int(self.evaluate_expression(tokens[1:]))
997
+ self.variables.loop_stack.append([line_no + 1, count, 1])
998
+ return line_no + 1 # Continue to the next line
934
999
 
935
1000
  def handle_proc(self, tokens, line_no):
936
1001
  # tokens[1] is proc name
@@ -941,7 +1006,7 @@ class CstolScriptEngine(ScriptEngine):
941
1006
  if token == ',':
942
1007
  continue
943
1008
  elif token.startswith('$'):
944
- self.variables.local_variables[token] = os.getenv(f"CSTOL_ARG_{index}")
1009
+ self.variables.set_local_variable(token, os.getenv(f"CSTOL_ARG_{index}"))
945
1010
  index += 1
946
1011
  else:
947
1012
  raise ValueError(f"Invalid variable '{token}' in PROC command at line {line_no}")
@@ -949,7 +1014,7 @@ class CstolScriptEngine(ScriptEngine):
949
1014
  def handle_return(self, tokens, lines, line_no):
950
1015
  if len(tokens) > 1:
951
1016
  if tokens[1].upper() == 'ALL':
952
- raise StopScript
1017
+ raise StopScriptError
953
1018
  return (len(lines) + 1)
954
1019
 
955
1020
  def handle_run(self, tokens, line_no):
@@ -1052,6 +1117,8 @@ class CstolScriptEngine(ScriptEngine):
1052
1117
  expressions = self.extract_expressions(tokens[1:], ",")
1053
1118
  results = []
1054
1119
  for expr in expressions:
1120
+ if len(expr) == 0:
1121
+ raise ValueError(f"Empty expression in WRITE command at line {line_no}")
1055
1122
  result = ''
1056
1123
  if expr[0][0] == '%':
1057
1124
  # Format string
@@ -1166,6 +1233,8 @@ class CstolScriptEngine(ScriptEngine):
1166
1233
  case "GO" | "GOTO":
1167
1234
  return self.handle_go(tokens, lines, line_no)
1168
1235
  case "IF":
1236
+ # Regular IF starts an if stack
1237
+ self.variables.if_stack.append(False)
1169
1238
  return self.handle_if(tokens, lines, line_no)
1170
1239
  case "LET":
1171
1240
  self.handle_let(tokens, line_no)
Binary file
metadata CHANGED
@@ -1,18 +1,18 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openc3-cosmos-script-engine-cstol
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
- - Ryan Melton
7
+ - OpenC3, Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-10-17 00:00:00.000000000 Z
11
+ date: 2026-06-26 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: " This plugin provides a script engine to support CSTOL in Script Runner\n"
14
14
  email:
15
- - ryan@openc3.com
15
+ - plugins@openc3.com
16
16
  executables: []
17
17
  extensions: []
18
18
  extra_rdoc_files: []
@@ -22,12 +22,19 @@ files:
22
22
  - Rakefile
23
23
  - lib/cstol_script_engine.py
24
24
  - plugin.txt
25
+ - public/store_img.png
25
26
  - targets/CSTOL_TEST/procedures/collect.prc
26
27
  - targets/CSTOL_TEST/procedures/test.prc
27
28
  homepage: https://github.com/OpenC3/openc3-cosmos-script-engine-cstol
28
29
  licenses:
29
30
  - Nonstandard
30
- metadata: {}
31
+ metadata:
32
+ source_code_uri: https://github.com/OpenC3/openc3-cosmos-script-engine-cstol
33
+ openc3_store_title: CSTOL Script Engine
34
+ openc3_store_keywords: script, cstol
35
+ openc3_store_image: public/store_img.png
36
+ openc3_store_access_type: public
37
+ openc3_cosmos_minimum_version: 7.0.0
31
38
  post_install_message:
32
39
  rdoc_options: []
33
40
  require_paths:
@@ -46,5 +53,5 @@ requirements: []
46
53
  rubygems_version: 3.5.22
47
54
  signing_key:
48
55
  specification_version: 4
49
- summary: OpenC3 Script Engine CSTOL
56
+ summary: OpenC3 CSTOL Script Engine
50
57
  test_files: []