@@ -153,7 +153,6 @@ def setUp(self):
153153 )
154154
155155 def test_creates_an_interface_updates_status_and_starts_cmd_thread (self ):
156- init_threads = threading .active_count ()
157156 im = InterfaceMicroservice ("DEFAULT__INTERFACE__INST_INT" )
158157 self .assertEqual (im .config ["name" ], "DEFAULT__INTERFACE__INST_INT" )
159158 self .assertEqual (im .interface .name , "INST_INT" )
@@ -165,13 +164,16 @@ def test_creates_an_interface_updates_status_and_starts_cmd_thread(self):
165164 self .assertEqual (data ["INST_INT" ]["name" ], "INST_INT" )
166165 self .assertEqual (data ["INST_INT" ]["state" ], "ATTEMPTING" )
167166
168- # Each interface microservice starts 3 threads: microservice_status_thread in microservice.rb
169- # and the InterfaceCmdHandlerThread in interface_microservice.rb
170- # and a metrics thread
171- self .assertEqual (threading .active_count () - init_threads , 3 )
167+ # The command handler thread is created and running. We check the handler
168+ # thread directly rather than the global thread count because the metrics
169+ # thread is a process-wide singleton, so the delta depends on test ordering
170+ # across the full suite.
171+ self .assertIsNotNone (im .handler_thread )
172+ self .assertTrue (im .handler_thread .thread .is_alive ())
173+
172174 im .shutdown ()
173- time . sleep ( 0.1 ) # Allow threads to exit
174- self .assertEqual ( threading . active_count (), init_threads )
175+ im . handler_thread . thread . join ( 5 ) # Wait for the handler to exit (no fixed sleep race)
176+ self .assertFalse ( im . handler_thread . thread . is_alive () )
175177
176178 # def test_preserves_existing_packet_counts(self):
177179 # # Initialize the telemetry topic with a non-zero RECEIVED_COUNT
@@ -505,7 +507,9 @@ def test_stale_tlmcnt_redis_key_does_not_disconnect_interface(self):
505507 self .assertIn ("Stale tlmcnt Redis key detected for unknown packet INST OLD_PACKET" , stdout .getvalue ())
506508
507509 def test_process_cmd_with_all_fields_and_missing_optional_fields (self ):
508- """Test process_cmd succeeds with full msg_hash and with only required fields."""
510+ """Test process_cmd succeeds with full msg_hash and with only required fields.
511+ Also verifies queue_username (the original author, shown as "Queued By")
512+ is carried through to the command extra."""
509513 im = InterfaceMicroservice ("DEFAULT__INTERFACE__INST_INT" )
510514 thread = threading .Thread (target = im .run )
511515 thread .start ()
@@ -528,12 +532,19 @@ def test_process_cmd_with_all_fields_and_missing_optional_fields(self):
528532 b"hazardous_check" : b"TRUE" ,
529533 b"cmd_string" : b"cmd('INST ABORT')" ,
530534 b"username" : b"test_user" ,
535+ b"queue_username" : b"DEFAULT__MULTI__INST" ,
531536 b"validate" : b"TRUE" ,
532537 b"manual" : b"FALSE" ,
533538 b"log_message" : b"TRUE" ,
534539 }
535- result = handler .process_cmd (topic , msg_id , full_msg_hash , None )
540+ with patch ("openc3.microservices.interface_microservice.CommandDecomTopic.write_packet" ) as mock_write :
541+ result = handler .process_cmd (topic , msg_id , full_msg_hash , None )
536542 self .assertEqual (result , "SUCCESS" )
543+ # queue_username must be copied into the command extra so Command History
544+ # can show "Queued By" for queued commands
545+ command = mock_write .call_args [0 ][0 ]
546+ self .assertEqual (command .extra ["username" ], "test_user" )
547+ self .assertEqual (command .extra .get ("queue_username" ), "DEFAULT__MULTI__INST" )
537548
538549 # Minimal msg_hash — only required fields; optional fields use .get() defaults
539550 minimal_msg_hash = {
@@ -557,6 +568,137 @@ def test_process_cmd_with_all_fields_and_missing_optional_fields(self):
557568 result = handler .process_cmd (topic , msg_id , full_msg_hash , None )
558569 self .assertIsNone (result )
559570
571+ def test_process_cmd_supports_interface_directives (self ):
572+ """Directive messages on the CMD}INTERFACE topic: interface_details and
573+ target_control (enable/disable and the error path)."""
574+ im = InterfaceMicroservice ("DEFAULT__INTERFACE__INST_INT" )
575+ self .addCleanup (im .shutdown )
576+ handler = im .handler_thread
577+ topic = "{DEFAULT__CMD}INTERFACE__INST_INT"
578+ msg_id = f"{ int (time .time () * 1000 )} -0"
579+
580+ # interface_details returns the interface details as JSON
581+ result = handler .process_cmd (topic , msg_id , {b"interface_details" : b"1" }, None )
582+ self .assertEqual (json .loads (result )["name" ], "INST_INT" )
583+
584+ # target_control disable turns off both cmd and tlm for the target
585+ disable = json .dumps (
586+ {"target_name" : "INST" , "cmd_only" : False , "tlm_only" : False , "action" : "disable" }
587+ ).encode ()
588+ self .assertEqual (handler .process_cmd (topic , msg_id , {b"target_control" : disable }, None ), "SUCCESS" )
589+ self .assertFalse (im .interface .cmd_target_enabled ["INST" ])
590+ self .assertFalse (im .interface .tlm_target_enabled ["INST" ])
591+
592+ # target_control enable turns them back on
593+ enable = json .dumps ({"target_name" : "INST" , "cmd_only" : False , "tlm_only" : False , "action" : "enable" }).encode ()
594+ self .assertEqual (handler .process_cmd (topic , msg_id , {b"target_control" : enable }, None ), "SUCCESS" )
595+ self .assertTrue (im .interface .cmd_target_enabled ["INST" ])
596+ self .assertTrue (im .interface .tlm_target_enabled ["INST" ])
597+
598+ # target_control with invalid JSON returns the error message (not SUCCESS)
599+ result = handler .process_cmd (topic , msg_id , {b"target_control" : b"not json" }, None )
600+ self .assertNotEqual (result , "SUCCESS" )
601+
602+ # A raw write while not connected reports that
603+ result = handler .process_cmd (topic , msg_id , {b"raw" : b"\x00 \x01 " }, None )
604+ self .assertEqual (result , "Interface not connected: INST_INT" )
605+
606+ # interface_cmd / protocol_cmd / inject_tlm error paths return the error
607+ self .assertNotEqual (handler .process_cmd (topic , msg_id , {b"interface_cmd" : b"{}" }, None ), "SUCCESS" )
608+ self .assertNotEqual (handler .process_cmd (topic , msg_id , {b"protocol_cmd" : b"{}" }, None ), "SUCCESS" )
609+ self .assertNotEqual (handler .process_cmd (topic , msg_id , {b"inject_tlm" : b"not valid" }, None ), "SUCCESS" )
610+
611+ def test_process_cmd_connected_interface_directives (self ):
612+ """Raw write and stream logging directives against a connected interface."""
613+ im = InterfaceMicroservice ("DEFAULT__INTERFACE__INST_INT" )
614+ thread = threading .Thread (target = im .run )
615+ thread .start ()
616+ self .addCleanup (thread .join , 5 )
617+ self .addCleanup (im .shutdown )
618+ time .sleep (0.1 )
619+
620+ handler = im .handler_thread
621+ topic = "{DEFAULT__CMD}INTERFACE__INST_INT"
622+ msg_id = f"{ int (time .time () * 1000 )} -0"
623+
624+ # Raw write to a connected interface results in an UNKNOWN packet
625+ self .assertEqual (handler .process_cmd (topic , msg_id , {b"raw" : b"\x00 \x01 \x02 \x03 " }, None ), "SUCCESS" )
626+
627+ # Enable then disable stream logging
628+ self .assertEqual (handler .process_cmd (topic , msg_id , {b"log_stream" : b"true" }, None ), "SUCCESS" )
629+ self .assertEqual (handler .process_cmd (topic , msg_id , {b"log_stream" : b"false" }, None ), "SUCCESS" )
630+
631+ def test_process_cmd_command_error_and_hazardous_branches (self ):
632+ """Hazardous check, invalid command, and not-connected branches — none
633+ of which require the interface to be connected."""
634+ im = InterfaceMicroservice ("DEFAULT__INTERFACE__INST_INT" )
635+ self .addCleanup (im .shutdown )
636+ handler = im .handler_thread
637+ topic = "{DEFAULT__CMD}TARGET__INST"
638+ msg_id = f"{ int (time .time () * 1000 )} -0"
639+
640+ # CLEAR is HAZARDOUS: with hazardous_check enabled it returns a HazardousError
641+ result = handler .process_cmd (
642+ topic ,
643+ msg_id ,
644+ {
645+ b"target_name" : b"INST" ,
646+ b"cmd_name" : b"CLEAR" ,
647+ b"cmd_params" : json .dumps ({}).encode (),
648+ b"hazardous_check" : b"TRUE" ,
649+ b"cmd_string" : b"cmd('INST CLEAR')" ,
650+ },
651+ None ,
652+ )
653+ self .assertTrue (result .startswith ("HazardousError" ))
654+
655+ # Neither cmd_params nor cmd_buffer present raises "Invalid command received"
656+ result = handler .process_cmd (topic , msg_id , {b"target_name" : b"INST" , b"cmd_name" : b"ABORT" }, None )
657+ self .assertIn ("Invalid command received" , result )
658+
659+ # A valid command while the interface is not connected reports that
660+ result = handler .process_cmd (
661+ topic ,
662+ msg_id ,
663+ {
664+ b"target_name" : b"INST" ,
665+ b"cmd_name" : b"ABORT" ,
666+ b"cmd_params" : json .dumps ({}).encode (),
667+ b"hazardous_check" : b"FALSE" ,
668+ },
669+ None ,
670+ )
671+ self .assertEqual (result , "Interface not connected: INST_INT" )
672+
673+ def test_process_cmd_identifies_a_cmd_buffer (self ):
674+ """A command sent as a raw cmd_buffer is identified and written to the
675+ connected interface."""
676+ im = InterfaceMicroservice ("DEFAULT__INTERFACE__INST_INT" )
677+ thread = threading .Thread (target = im .run )
678+ thread .start ()
679+ self .addCleanup (thread .join , 5 )
680+ self .addCleanup (im .shutdown )
681+ time .sleep (0.1 )
682+
683+ handler = im .handler_thread
684+ topic = "{DEFAULT__CMD}TARGET__INST"
685+ msg_id = f"{ int (time .time () * 1000 )} -0"
686+
687+ abort = System .commands .build_cmd ("INST" , "ABORT" )
688+ result = handler .process_cmd (
689+ topic ,
690+ msg_id ,
691+ {
692+ b"target_name" : b"INST" ,
693+ b"cmd_name" : b"ABORT" ,
694+ b"cmd_buffer" : abort .buffer ,
695+ b"cmd_string" : b"cmd('INST ABORT')" ,
696+ b"username" : b"test_user" ,
697+ },
698+ None ,
699+ )
700+ self .assertEqual (result , "SUCCESS" )
701+
560702 def test_run_does_not_write_status_after_cancel_thread_set (self ):
561703 """When stop() sets cancel_thread, disconnect() and run() must not
562704 write to the status model, avoiding re-creation after stop() deletes it."""
0 commit comments