Skip to content

Commit fa77c6c

Browse files
jmthomasclaude
andcommitted
fix(interface): populate Queued By for Python target commands
The Python interface microservice never copied queue_username into the command extra, so Command History showed no "Queued By" for commands sent through Python targets (the Ruby path in interface_microservice.rb did). Also fix two Ruby-port bugs in RouterTlmHandlerThread found while adding router tests: router_cmd/protocol_cmd crashed joining non-string params, and the shutdown path blocked on another read before breaking so the handler thread never exited cleanly. Add router microservice test harness and expand interface tests, raising interface_microservice.py coverage from 59% to 80%. Harden both test_creates thread assertions to check the handler thread directly (join on shutdown) rather than a flaky global thread count. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c3e6ae7 commit fa77c6c

4 files changed

Lines changed: 443 additions & 22 deletions

File tree

openc3/python/openc3/microservices/interface_microservice.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,9 @@ def process_cmd(self, topic, msg_id, msg_hash, _redis):
324324
command.extra["cmd_string"] = msg_hash.get(b"cmd_string", b"").decode()
325325
command.extra["username"] = msg_hash.get(b"username", b"").decode()
326326
command.extra["interface_name"] = self.interface.name
327+
# Record the original queuing user (author) shown as "Queued By" in Command History
328+
if msg_hash.get(b"queue_username"):
329+
command.extra["queue_username"] = msg_hash.get(b"queue_username", b"").decode()
327330
# Add approver info if this was a critical command that was approved
328331
if critical_model is not None:
329332
command.extra["approver"] = critical_model.approver
@@ -512,9 +515,8 @@ def run(self):
512515
elif msg_hash.get(b"router_cmd"):
513516
params = json.loads(msg_hash[b"router_cmd"])
514517
try:
515-
self.logger.info(
516-
f"{self.router.name}: router_cmd: {params['cmd_name']} {' '.join(params['cmd_params'])}"
517-
)
518+
str_params = " ".join([str(i) for i in params["cmd_params"]])
519+
self.logger.info(f"{self.router.name}: router_cmd: {params['cmd_name']} {str_params}")
518520
self.router.interface_cmd(params["cmd_name"], *params["cmd_params"])
519521
result = "SUCCESS"
520522
except Exception as error:
@@ -523,8 +525,9 @@ def run(self):
523525
elif msg_hash.get(b"protocol_cmd"):
524526
params = json.loads(msg_hash[b"protocol_cmd"])
525527
try:
528+
str_params = " ".join([str(i) for i in params["cmd_params"]])
526529
self.logger.info(
527-
f"{self.router.name}: protocol_cmd: {params['cmd_name']} {' '.join(params['cmd_params'])} read_write: {params['read_write']} index: {params['index']}"
530+
f"{self.router.name}: protocol_cmd: {params['cmd_name']} {str_params} read_write: {params['read_write']} index: {params['index']}"
528531
)
529532
self.router.protocol_cmd(
530533
params["cmd_name"],
@@ -596,13 +599,14 @@ def run(self):
596599
else:
597600
result = None
598601

599-
# Send result back to generator and get next message
600-
topic, msg_id, msg_hash, _redis = generator.send(result)
601-
602-
# Exit loop if shutdown was requested
602+
# Exit loop if shutdown was requested (matches the Ruby behavior of
603+
# returning immediately rather than reading another message)
603604
if result == "SHUTDOWN":
604605
break
605606

607+
# Send result back to generator and get next message
608+
topic, msg_id, msg_hash, _redis = generator.send(result)
609+
606610

607611
class InterfaceMicroservice(Microservice):
608612
UNKNOWN_BYTES_TO_PRINT = 16

openc3/python/test/microservices/test_interface_microservice.py

Lines changed: 151 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)