Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<node>' 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`
Expand Down
6 changes: 6 additions & 0 deletions docs/v1-scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
13 changes: 12 additions & 1 deletion src/cubesat_testbed/scenario/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down
145 changes: 145 additions & 0 deletions tests/test_external_command_routes.py
Original file line number Diff line number Diff line change
@@ -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))
Loading