From a8c80a57db522acce15bb0c09e29a767d5a94dfe Mon Sep 17 00:00:00 2001 From: glocker Date: Tue, 10 Nov 2026 15:28:58 +0000 Subject: [PATCH] Observe commands targeting external nodes --- CHANGELOG.md | 10 ++ docs/v1-scope.md | 6 + src/cubesat_testbed/scenario/runner.py | 13 ++- tests/test_external_command_routes.py | 145 +++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 tests/test_external_command_routes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5163cdc..bf2c136 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,16 @@ Versioning follows [Semantic Versioning](https://semver.org/). ### Fixed +- A configured command whose target is a `software`/`hardware` node no longer aborts + the run with `simulated module '' is not available`. Two situations triggered it: + the testbed overhearing a real peer send that command to another real peer (on + SocketCAN, on a ZeroMQ hub, which shows every peer's traffic, or on the in-memory + bus), and `send_command` towards an external node on the in-memory bus, which hands + the testbed's own frame back to it. Such a command is now only observed: `--trace` + still names its route, and nothing is delivered because no model of ours owns the + target. A command to a simulated node is delivered exactly as before, and an ambiguous + route is still a hard error. The semantic payload of a command sent to an external + node is no longer kept in memory for the life of the runner. - KISS: a payload of 253-256 bytes passed config validation and the sender's size check, then failed mid-run with a raw `TransportError`, because the 4-byte CRC32 shares the packet buffer with the payload. The limit is now `max_packet_bytes − 4` diff --git a/docs/v1-scope.md b/docs/v1-scope.md index caa7ce4..df7a9a2 100644 --- a/docs/v1-scope.md +++ b/docs/v1-scope.md @@ -464,6 +464,12 @@ transport, whether that traffic comes from a real board, a real process on `vcan0`, on the other end of a serial line or on a ZeroMQ hub, or a scenario test manually placing frames on the in-memory bus. +A configured command is delivered to a module only when its *target* is simulated. +When the target is a `software`/`hardware` node, a real peer handles the command, so +seeing it on the bus (the testbed's own `send_command` coming back on the in-memory +bus, or two real nodes talking on a bus the testbed only watches) is observation: it is +traced with its route and nothing else happens. + By default, `wait()` uses `cubesat_testbed.clock.VirtualClock` and jumps straight to its target virtual time, exactly as before. Passing `clock=RealTimeClock()` to `run_scenario`/`run_scenario_files`/`build_runtime` diff --git a/src/cubesat_testbed/scenario/runner.py b/src/cubesat_testbed/scenario/runner.py index 3d2f7e1..597e31c 100644 --- a/src/cubesat_testbed/scenario/runner.py +++ b/src/cubesat_testbed/scenario/runner.py @@ -602,7 +602,9 @@ def _send_route(self, route: _CommandRoute, *, payload: object) -> TransportEnve ), route.payload_hex, ) - if payload is not None: + # Only a simulated target ever receives the semantic payload back; for an external + # node nothing would pop the entry, so do not keep one. + if payload is not None and route.target_node in self.runtime.modules: self._semantic_payloads_by_envelope[envelope.sequence] = payload return envelope @@ -772,6 +774,15 @@ def _deliver_command( packet: CspPacketView, envelope: TransportEnvelope, ) -> None: + if route.target_node not in self.runtime.modules: + # The target is a software/hardware node: a real peer handles this command, + # not a model of ours. Seeing it on the bus is observation -- the trace already + # names the route -- not a request to run something that does not exist, which + # is what happens when the testbed merely overhears two other nodes talking, + # or when the in-memory bus hands the testbed's own command back to it. + self._semantic_payloads_by_envelope.pop(envelope.sequence, None) + return + module = self.runtime.module(route.target_node) if not isinstance(module, _CommandHandlingModule): raise ScenarioRuntimeError(f"module {route.target_node!r} cannot handle commands") diff --git a/tests/test_external_command_routes.py b/tests/test_external_command_routes.py new file mode 100644 index 0000000..9fc2187 --- /dev/null +++ b/tests/test_external_command_routes.py @@ -0,0 +1,145 @@ +"""A command aimed at a software/hardware node is observed, never "delivered" to a model. + +Regression tests for runs that used to abort with ``simulated module 'payload' is not +available`` when the testbed saw a configured command whose target a real peer owns. +""" + +from __future__ import annotations + +import io +import warnings + +import pytest + +from cubesat_testbed.config import parse_scenario, parse_testbed_config +from cubesat_testbed.protocol.csp_v2 import CspFields, pack +from cubesat_testbed.scenario import ScenarioRunner, build_runtime + +SETUP = """ +[transport] +type = "in-memory" + +[nodes.obc] +mode = "simulated" +module_type = "obc_peer" +address = 1 + +[nodes.eps] +mode = "{eps_mode}" +address = 2 + +[nodes.payload] +mode = "{payload_mode}" +{payload_module} +address = 3 + +[nodes.eps.commands.{eps_command}] +target = "payload" +destination_port = 10 +source_port = 11 +payload_hex = "00" + +[nodes.obc.commands.shed_from_obc] +target = "payload" +destination_port = 12 +source_port = 13 +payload_hex = "01" +""" + +WAIT = "name: observe\nsteps:\n - action: wait\n virtual_time: 1s\n" +SEND = ( + "name: send\nsteps:\n - action: send_command\n command: shed_from_obc\n" + " source: obc\n target: payload\n payload: {reason: test}\n" + " - action: wait\n virtual_time: 1s\n" +) +EPS_TO_PAYLOAD = CspFields(priority=2, source=2, destination=3, destination_port=10, source_port=11) + + +def _setup(*, eps_mode: str, payload_mode: str, eps_command: str = "shed"): # type: ignore[no-untyped-def] + module = 'module_type = "simple_payload"' if payload_mode == "simulated" else "" + return parse_testbed_config( + SETUP.format( + eps_mode=eps_mode, + payload_mode=payload_mode, + payload_module=module, + eps_command=eps_command, + ) + ) + + +def test_traffic_between_two_other_nodes_matching_a_command_route_is_only_observed() -> None: + setup = _setup(eps_mode="software", payload_mode="software") + runtime = build_runtime(setup) + # A real eps and a real payload talking on a bus the testbed merely watches. + runtime.transport.send(pack(EPS_TO_PAYLOAD, b"\x00"), source=2) + runner = ScenarioRunner(runtime, output=io.StringIO()) + + with warnings.catch_warnings(): + warnings.simplefilter("error") # a routed command is not an "unrouted frame" either + result = runner.run(parse_scenario(WAIT, setup=setup)) + + assert result.passed and result.dropped_packets == () + + +def test_send_command_to_an_external_node_on_the_in_memory_bus_runs_and_is_traced() -> None: + setup = _setup(eps_mode="software", payload_mode="software") + trace = io.StringIO() + runtime = build_runtime(setup, trace=trace) + runner = ScenarioRunner(runtime, output=io.StringIO()) + + result = runner.run(parse_scenario(SEND, setup=setup)) + + assert result.passed + lines = trace.getvalue().splitlines() + # The in-memory bus hands the testbed's own frame back, so it shows as TX and RX. + assert any(" TX " in line and "command obc.shed_from_obc->payload" in line for line in lines) + assert any(" RX " in line and "command obc.shed_from_obc->payload" in line for line in lines) + + +def test_no_semantic_payload_is_kept_for_a_command_nobody_will_deliver() -> None: + setup = _setup(eps_mode="software", payload_mode="software") + runner = ScenarioRunner(build_runtime(setup), output=io.StringIO()) + + runner.run(parse_scenario(SEND, setup=setup)) + + assert runner._semantic_payloads_by_envelope == {} + + +def test_a_command_to_a_simulated_target_is_still_delivered_with_its_payload() -> None: + # simple_payload understands this command name, unlike the made-up "shed". + setup = _setup(eps_mode="software", payload_mode="simulated", eps_command="payload_power_off") + runtime = build_runtime(setup) + runner = ScenarioRunner(runtime, output=io.StringIO()) + payload = runtime.module("payload") + received: list[object] = [] + original = payload.handle_command # type: ignore[attr-defined] + + def spy(command): # type: ignore[no-untyped-def] + received.append(command) + return original(command) + + payload.handle_command = spy # type: ignore[attr-defined,method-assign] + runtime.transport.send(pack(EPS_TO_PAYLOAD, b"\x00"), source=2) + + runner.run(parse_scenario(WAIT, setup=setup)) + + assert [c.command for c in received] == ["payload_power_off"] # type: ignore[attr-defined] + assert runner._semantic_payloads_by_envelope == {} + + +def test_an_ambiguous_route_is_still_a_hard_error() -> None: + """Observing is for routes whose target is external; a config bug stays loud.""" + + text = SETUP.format( + eps_mode="software", payload_mode="software", payload_module="", eps_command="shed" + ) + text += ( + '\n[nodes.eps.commands.shed_twin]\ntarget = "payload"\ndestination_port = 10\n' + 'source_port = 11\npayload_hex = "00"\n' + ) + setup = parse_testbed_config(text) + runtime = build_runtime(setup) + runtime.transport.send(pack(EPS_TO_PAYLOAD, b"\x00"), source=2) + + with pytest.raises(Exception, match="multiple configured routes"): + ScenarioRunner(runtime, output=io.StringIO()).run(parse_scenario(WAIT, setup=setup))