206 lines
7.6 KiB
Python
206 lines
7.6 KiB
Python
|
|
"""Fakes standing in for the hardware, so the whole suite runs unplugged.
|
||
|
|
|
||
|
|
Three levels are faked, matching the three seams in the code:
|
||
|
|
|
||
|
|
FakeUsbDevice a CH347 at the libusb boundary -- ch347.py's own framing is
|
||
|
|
then the thing under test, byte for byte.
|
||
|
|
FakeBus an I2C master at the rm3100.py boundary, holding a register
|
||
|
|
map, so the driver's register sequences are under test.
|
||
|
|
FakeSensor an RM3100 at the logger.py boundary, replaying a scripted
|
||
|
|
timeline of DRDY transitions, so the miss-counting maths is
|
||
|
|
under test without waiting on real time.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import sys
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
# The modules under test sit at the repo root, next to this directory.
|
||
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||
|
|
|
||
|
|
|
||
|
|
class FakeUsbDevice:
|
||
|
|
"""Records bulk writes and replays queued bulk reads."""
|
||
|
|
|
||
|
|
def __init__(self):
|
||
|
|
self.writes = [] # bytes sent to EP_OUT, in order
|
||
|
|
self.replies = [] # bytes to hand back, one per read()
|
||
|
|
self.short_write_by = 0 # subtract this from the reported write length
|
||
|
|
|
||
|
|
def queue(self, *replies):
|
||
|
|
"""Queue one reply per expected read, as bytes or an iterable of ints."""
|
||
|
|
self.replies.extend(bytes(r) for r in replies)
|
||
|
|
return self
|
||
|
|
|
||
|
|
def write(self, endpoint, data, timeout):
|
||
|
|
self.writes.append(bytes(data))
|
||
|
|
return len(data) - self.short_write_by
|
||
|
|
|
||
|
|
def read(self, endpoint, length, timeout):
|
||
|
|
if not self.replies:
|
||
|
|
raise AssertionError(
|
||
|
|
f"unexpected read of {length} bytes: nothing queued")
|
||
|
|
return bytearray(self.replies.pop(0))
|
||
|
|
|
||
|
|
@property
|
||
|
|
def last_write(self):
|
||
|
|
return self.writes[-1]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def usb_device(monkeypatch):
|
||
|
|
"""A CH347 that never touches USB. Yields the device; build the bus with it."""
|
||
|
|
import usb.core
|
||
|
|
import usb.util
|
||
|
|
|
||
|
|
device = FakeUsbDevice()
|
||
|
|
monkeypatch.setattr(usb.core, "find", lambda **kwargs: device)
|
||
|
|
monkeypatch.setattr(usb.util, "claim_interface", lambda *a: None)
|
||
|
|
monkeypatch.setattr(usb.util, "release_interface", lambda *a: None)
|
||
|
|
monkeypatch.setattr(usb.util, "dispose_resources", lambda *a: None)
|
||
|
|
return device
|
||
|
|
|
||
|
|
|
||
|
|
class FakeBus:
|
||
|
|
"""An I2C bus holding one device's register map.
|
||
|
|
|
||
|
|
Implements write/read/write_read with the same (data, mono, wall) contract
|
||
|
|
as ch347.CH347I2C, so rm3100.RM3100 cannot tell the difference.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self, registers=None, address=0x23, combined=True):
|
||
|
|
self.address = address
|
||
|
|
self.registers = dict(registers or {})
|
||
|
|
self.writes = [] # (addr, bytes) of every write
|
||
|
|
self.pointer = 0
|
||
|
|
self.clock = 1000.0
|
||
|
|
if not combined:
|
||
|
|
# Some buses cannot do a repeated START; the driver has a fallback
|
||
|
|
# path for them and it needs exercising too. read_reg() looks the
|
||
|
|
# method up with getattr(..., None), so hiding it this way is the
|
||
|
|
# same to the driver as never having had one.
|
||
|
|
self.write_read = None
|
||
|
|
|
||
|
|
def _stamp(self):
|
||
|
|
self.clock += 0.001
|
||
|
|
return self.clock, self.clock + 1_700_000_000.0
|
||
|
|
|
||
|
|
def write(self, addr, data):
|
||
|
|
data = bytes(data)
|
||
|
|
self.writes.append((addr, data))
|
||
|
|
self.pointer = data[0]
|
||
|
|
for offset, value in enumerate(data[1:]):
|
||
|
|
self.registers[data[0] + offset] = value
|
||
|
|
|
||
|
|
def read(self, addr, count):
|
||
|
|
mono, wall = self._stamp()
|
||
|
|
data = bytes(self.registers.get(self.pointer + i, 0)
|
||
|
|
for i in range(count))
|
||
|
|
return data, mono, wall
|
||
|
|
|
||
|
|
def write_read(self, addr, data, count):
|
||
|
|
self.write(addr, data)
|
||
|
|
return self.read(addr, count)
|
||
|
|
|
||
|
|
|
||
|
|
class FakeSensor:
|
||
|
|
"""An RM3100 replaying a scripted DRDY timeline against a fake clock.
|
||
|
|
|
||
|
|
`events` is a list of (monotonic_time, counts) pairs: the instants at which a
|
||
|
|
measurement completes and what it reads. poll_ready() reports DRDY set once
|
||
|
|
the clock has passed the next event and the previous result was consumed.
|
||
|
|
|
||
|
|
Every poll and read advances the clock by a fixed cost, so a test states its
|
||
|
|
timeline in seconds and gets deterministic brackets out.
|
||
|
|
"""
|
||
|
|
|
||
|
|
POLL_COST = 0.0003
|
||
|
|
READ_COST = 0.0005
|
||
|
|
|
||
|
|
class Exhausted(Exception):
|
||
|
|
"""Raised once max_polls is reached, to stop an unbounded run."""
|
||
|
|
|
||
|
|
def __init__(self, events, poll_cost=POLL_COST, read_cost=READ_COST,
|
||
|
|
stalls=None, max_polls=None):
|
||
|
|
self.events = list(events)
|
||
|
|
self.poll_cost = poll_cost
|
||
|
|
self.read_cost = read_cost
|
||
|
|
# [(at_time, extra_seconds), ...] -- the first poll starting at or after
|
||
|
|
# at_time pays extra_seconds before returning. A host stall is the only
|
||
|
|
# thing that actually loses a measurement on this rig, and stating it in
|
||
|
|
# seconds rather than in poll counts keeps a test independent of how
|
||
|
|
# many times the loop happens to poll.
|
||
|
|
self.stalls = sorted(stalls or [])
|
||
|
|
# A run with no --duration never returns on its own, so a test of that
|
||
|
|
# path needs the sensor to be the thing that stops it.
|
||
|
|
self.max_polls = max_polls
|
||
|
|
self.now = 0.0
|
||
|
|
self.pending = None # counts of a completed, unread measurement
|
||
|
|
self.next_event = 0
|
||
|
|
self.polls = 0
|
||
|
|
self.reads = []
|
||
|
|
|
||
|
|
# -- clock plumbing -------------------------------------------------
|
||
|
|
def monotonic(self):
|
||
|
|
return self.now
|
||
|
|
|
||
|
|
def wall(self):
|
||
|
|
return self.now + 1_700_000_000.0
|
||
|
|
|
||
|
|
def _advance(self, dt):
|
||
|
|
self.now += dt
|
||
|
|
while (self.next_event < len(self.events)
|
||
|
|
and self.events[self.next_event][0] <= self.now):
|
||
|
|
# A completion overwrites any unread one: the chip does not queue,
|
||
|
|
# which is exactly why an unnoticed miss loses a measurement.
|
||
|
|
self.pending = self.events[self.next_event][1]
|
||
|
|
self.next_event += 1
|
||
|
|
|
||
|
|
# -- the RM3100 surface logger.py uses ------------------------------
|
||
|
|
def poll_ready(self):
|
||
|
|
self.polls += 1
|
||
|
|
if self.max_polls is not None and self.polls > self.max_polls:
|
||
|
|
raise self.Exhausted(f"stopped after {self.max_polls} polls")
|
||
|
|
stall = 0.0
|
||
|
|
if self.stalls and self.now >= self.stalls[0][0]:
|
||
|
|
stall = self.stalls.pop(0)[1]
|
||
|
|
self._advance(self.poll_cost + stall)
|
||
|
|
return self.pending is not None, self.now, self.wall()
|
||
|
|
|
||
|
|
def read_raw(self):
|
||
|
|
self._advance(self.read_cost)
|
||
|
|
counts = self.pending if self.pending is not None else (0, 0, 0)
|
||
|
|
self.pending = None
|
||
|
|
self.reads.append(counts)
|
||
|
|
return counts, self.now, self.wall()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def fake_clock(monkeypatch):
|
||
|
|
"""Point time.monotonic and time.time at a FakeSensor's clock.
|
||
|
|
|
||
|
|
sample_loop() binds `monotonic = time.monotonic` when it runs, not at import,
|
||
|
|
so patching the module attribute is enough to control it.
|
||
|
|
"""
|
||
|
|
import time
|
||
|
|
|
||
|
|
def install(sensor):
|
||
|
|
monkeypatch.setattr(time, "monotonic", sensor.monotonic)
|
||
|
|
monkeypatch.setattr(time, "time", sensor.wall)
|
||
|
|
return sensor
|
||
|
|
|
||
|
|
return install
|
||
|
|
|
||
|
|
|
||
|
|
def grid_events(period, count, start=None, counts=(100, 200, 300), skip=()):
|
||
|
|
"""Measurement completions on a uniform grid, optionally dropping some.
|
||
|
|
|
||
|
|
A skipped index is a completion the chip still makes -- the grid never
|
||
|
|
pauses -- so it is present here; a test forces a *miss* by making the host
|
||
|
|
too slow to read it, not by removing it.
|
||
|
|
"""
|
||
|
|
start = period if start is None else start
|
||
|
|
return [(start + i * period, (counts[0] + i, counts[1], counts[2]))
|
||
|
|
for i in range(count) if i not in skip]
|