Source code for cubemarspycan.sim.harness
"""Wiring for tests and demos.
Two independent ``can.Bus(interface="virtual")`` instances on one channel: one owned by
the library's transport, one by the simulator. python-can delivers between instances on
the same channel, so the **real** receive path runs - notifier thread, sink dispatch,
routing, ``accepts``, codec, latch - rather than a monkeypatched stand-in. That is what
makes a green CI run on a laptop mean something.
:class:`SteppedSim` is driven by the test (``pump``/``step``), so nothing depends on wall
clock and there are no flaky sleeps.
"""
from __future__ import annotations
import itertools
import time
from dataclasses import dataclass, field
import can
from ..bus import MotorBus
from ..frame import Frame
from ..transport.can_bus import CanTransport
from .mit import SimMitDriver
from .servo import SimServoDriver
_channels = itertools.count()
[docs]
@dataclass
class SteppedSim:
"""A fake bus segment the test advances by hand."""
bus: can.BusABC
mit_drivers: list[SimMitDriver] = field(default_factory=list)
servo_drivers: list[SimServoDriver] = field(default_factory=list)
dropped: int = 0
received: list[Frame] = field(default_factory=list)
"""Every command frame the sim saw, in order. Lets tests assert on what was sent."""
_drop_every: int = 0
_seen: int = 0
frozen: bool = False
settle_s: float = 3.0e-4
"""Yield to the notifier thread after each step.
Replies go out over a real python-can virtual bus and are delivered by a real
notifier thread. A test loop that only sends and steps never releases the GIL, so
that thread is starved and no feedback ever lands. A short sleep hands it the
interpreter, which is also what a real control loop does while it waits for its
period."""
# --- knobs ----------------------------------------------------------------------
[docs]
def freeze(self) -> None:
"""Stop replying entirely, as a motor that has lost power would."""
self.frozen = True
[docs]
def thaw(self) -> None:
"""Resume replying after :meth:`freeze`. Idempotent."""
self.frozen = False
[docs]
def drop_every(self, n: int) -> None:
"""Drop one reply in ``n``. 0 disables."""
self._drop_every = n
# --- driving --------------------------------------------------------------------
[docs]
def pump(self) -> int:
"""Consume every pending command and emit replies. Returns frames handled."""
handled = 0
while True:
msg = self.bus.recv(timeout=0.0)
if msg is None:
return handled
handled += 1
frame = Frame(msg.arbitration_id, bytes(msg.data), msg.is_extended_id)
self.received.append(frame)
if self.frozen:
continue
replies: list[Frame] = []
for driver in self.mit_drivers:
reply = driver.handle(frame)
if reply is not None:
replies.append(reply)
for servo in self.servo_drivers:
replies.extend(servo.handle(frame))
for reply in replies:
self._send(reply)
[docs]
def step(self, dt: float) -> None:
"""Advance every plant, and emit any periodic servo status frames."""
for driver in self.mit_drivers:
driver.step(dt)
for servo in self.servo_drivers:
for frame in servo.step(dt):
if not self.frozen:
self._send(frame)
[docs]
def advance(self, dt: float) -> None:
"""One control period: handle what arrived, advance time, let the reader run."""
self.pump()
self.step(dt)
self.settle()
[docs]
def settle(self) -> None:
"""Give the notifier thread a chance to deliver what was just sent."""
if self.settle_s > 0.0:
time.sleep(self.settle_s)
[docs]
def inject(self, frame: Frame) -> None:
"""Put an arbitrary frame on the bus, for adversarial tests."""
self._send(frame)
def _send(self, frame: Frame) -> None:
self._seen += 1
if self._drop_every and self._seen % self._drop_every == 0:
self.dropped += 1
return
self.bus.send(
can.Message(
arbitration_id=frame.arbitration_id,
data=frame.data,
is_extended_id=frame.is_extended_id,
)
)
[docs]
def close(self) -> None:
"""Stop the notifier and shut down the simulator's own bus.
The library's transport is closed separately - the two ends are deliberately
distinct buses so that CI exercises the real receive path.
"""
self.bus.shutdown()
[docs]
def sim_bus(
mit_drivers: list[SimMitDriver] | None = None,
servo_drivers: list[SimServoDriver] | None = None,
channel: str | None = None,
) -> tuple[MotorBus, SteppedSim]:
"""Build a started :class:`~cubemarspycan.bus.MotorBus` wired to a :class:`SteppedSim`.
Caller closes both; :meth:`SteppedSim.close` and ``MotorBus.close`` are independent.
"""
name = channel or f"cubemars-sim-{next(_channels)}"
library_bus = can.interface.Bus(channel=name, interface="virtual", receive_own_messages=False)
sim_side = can.interface.Bus(channel=name, interface="virtual", receive_own_messages=False)
transport = CanTransport(library_bus, owns_bus=True)
motor_bus = MotorBus(transport)
motor_bus.start()
return motor_bus, SteppedSim(
bus=sim_side,
mit_drivers=list(mit_drivers or []),
servo_drivers=list(servo_drivers or []),
)