Python: the rb9704 command line, a fake modem, and the tests

rb9704 drives a 9704 on USB and the Cloudloop account behind it, moved
from WII6's host/rockblock with its project names taken out. rb9704-fake
speaks JSPR on a pty or a serial port: everything the library asks, MO in
segments with the CRC checked, MT, signal, provisioning, refusals and
failures on command.

The tests run against the fake at every level: the fake itself, the
command line, Ground Control's Python binding, the shell sample on
native_sim, and the sample on a board (test_zephyr.py, one suite through
two consoles). test_real.py makes free checks against a real modem.
This commit is contained in:
scottp committed 2026-10-09 12:48:16 +11:00
1 parent dbed9bbc88
commit 917522d1f4
12 files changed
+1861

No files matched your search

+224
View File
@@ -0,0 +1,224 @@
"""
The test harness: a fake modem in a process of its own, and a console to
drive whatever is under test.
pytest the fake and the CLI, and the
library's Python binding if
rockblock9704 is installed
pytest --zephyr-exe build/zephyr/zephyr.exe
... and Zephyr on native_sim
pytest --board-console /dev/ttyACM0 --board-modem /dev/ttyUSB0
... and Zephyr on a board: its
shell on one port, its modem
UART wired to a USB-serial
adapter on the other, where the
fake modem answers
pytest --real-modem /dev/ttyUSB0 [--zephyr-exe ...]
... and the free checks against
a real modem: nothing is sent
The native_sim and board suites are the same tests (test_zephyr.py) run
through a different console.
"""
import os
import queue
import re
import subprocess
import sys
import threading
import time
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
def pytest_addoption(parser):
g = parser.getgroup("rb9704")
g.addoption("--zephyr-exe", default=os.environ.get("RB9704_ZEPHYR_EXE"),
help="the shell sample built for native_sim (zephyr.exe)")
g.addoption("--board-console", default=os.environ.get("RB9704_BOARD_CONSOLE"),
help="a board's shell, with the shell sample flashed")
g.addoption("--board-modem", default=os.environ.get("RB9704_BOARD_MODEM"),
help="the host serial port wired to the board's modem UART")
g.addoption("--board-baud", type=int, default=115200)
g.addoption("--real-modem", default=os.environ.get("RB9704_REAL_MODEM"),
help="a real RockBLOCK 9704's port, for the free checks in test_real.py")
class Lines:
"""Lines from a stream, collected on a thread, waited on by regex."""
def __init__(self, stream):
self.q = queue.Queue()
self.seen = []
threading.Thread(target=self._read, args=(stream,), daemon=True).start()
def _read(self, stream):
for line in stream:
self.q.put(ANSI.sub("", line).rstrip("\r\n"))
def expect(self, pattern, timeout=10.0):
rx = re.compile(pattern)
end = time.monotonic() + timeout
while True:
for line in self.seen:
m = rx.search(line)
if m:
self.seen = self.seen[self.seen.index(line) + 1:]
return m
left = end - time.monotonic()
if left <= 0:
raise AssertionError("no line matching %r in %.0f s; last lines:\n%s"
% (pattern, timeout, "\n".join(self.seen[-15:])))
try:
self.seen.append(self.q.get(timeout=min(left, 0.2)))
except queue.Empty:
pass
def clear(self):
while not self.q.empty():
self.seen.append(self.q.get())
self.seen = []
class Fake:
"""rb9704-fake in a subprocess, on a pty or on a real port."""
def __init__(self, port=None, topics="244"):
args = [sys.executable, "-m", "rb9704tools.fakemodem", "--quiet", "--topics", topics]
if port:
args += ["--port", port]
self.p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
text=True, bufsize=1,
cwd=os.path.dirname(HERE))
self.out = Lines(self.p.stdout)
self.port = self.out.expect(r"^READY (\S+)").group(1)
def cmd(self, line):
self.p.stdin.write(line + "\n")
self.p.stdin.flush()
def close(self):
try:
self.cmd("quit")
self.p.wait(timeout=3)
except Exception:
self.p.kill()
@pytest.fixture
def fake():
f = Fake()
yield f
f.close()
class NativeConsole:
"""The shell sample on native_sim, its modem UART on the fake's pty."""
def __init__(self, exe, modem_port):
self.p = subprocess.Popen([exe, "-uart_stdinout", "-modem_uart_port=" + modem_port],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, bufsize=1)
self.out = Lines(self.p.stdout)
# Up when the shell answers; the boot log line can come before the
# shell's log backend is there to print it.
for _ in range(20):
self.sh("rb9704 status")
try:
self.out.expect(r"powered", timeout=0.5)
return
except AssertionError:
pass
self.p.kill()
raise AssertionError("native_sim's shell did not answer")
def sh(self, line):
self.out.clear()
self.p.stdin.write(line + "\n")
self.p.stdin.flush()
def close(self):
self.p.kill()
class SerialConsole:
"""The shell sample on a board, over its console port."""
def __init__(self, port, baud):
import serial
self.s = serial.Serial(port, baud, timeout=0.1, write_timeout=2)
self.out = Lines(self._lines())
self.sh("")
self.sh("rb9704 end") # a clean start whatever the board was doing
def _lines(self):
buf = b""
while True:
try:
buf += self.s.read(512)
except Exception:
return
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
yield line.decode(errors="replace")
def sh(self, line):
self.out.clear()
data = (line + "\r").encode()
# A shell's receive buffer is small; typed in one burst a long line
# loses characters. A person types slower than this.
for i in range(0, len(data), 16):
self.s.write(data[i:i + 16])
time.sleep(0.01)
time.sleep(0.2)
def close(self):
self.s.close()
def _targets(config):
t = []
if config.getoption("--zephyr-exe"):
t.append("native_sim")
if config.getoption("--board-console"):
t.append("board")
return t
def pytest_generate_tests(metafunc):
if "zephyr" in metafunc.fixturenames:
targets = _targets(metafunc.config)
metafunc.parametrize("zephyr", targets or ["none"], indirect=True)
@pytest.fixture
def zephyr(request):
"""(console, fake): Zephyr under test and the fake modem it talks to."""
cfg = request.config
if request.param == "none":
pytest.skip("no Zephyr target: --zephyr-exe or --board-console")
topics = getattr(request, "node").get_closest_marker("topics")
topics = topics.args[0] if topics else "244"
if request.param == "board" and not cfg.getoption("--board-modem"):
pytest.skip("--board-console needs --board-modem")
f = Fake(port=cfg.getoption("--board-modem") if request.param == "board" else None,
topics=topics)
try:
if request.param == "native_sim":
c = NativeConsole(cfg.getoption("--zephyr-exe"), f.port)
else:
c = SerialConsole(cfg.getoption("--board-console"), cfg.getoption("--board-baud"))
except Exception:
f.close()
raise
yield c, f
c.close()
f.close()
def pytest_configure(config):
config.addinivalue_line("markers", "topics(ids): the fake modem's provisioning")
+46
View File
@@ -0,0 +1,46 @@
"""The rb9704 command line against the fake modem."""
import subprocess
import sys
import pytest
def run(*args, timeout=30):
return subprocess.run([sys.executable, "-m", "rb9704tools.cli", *args], capture_output=True,
text=True, timeout=timeout)
def test_jspr_raw(fake):
r = run("-p", fake.port, "jspr", "--setup", "GET hwInfo {}")
assert r.returncode == 0, r.stderr
assert "300434030000000" in r.stdout
def test_provision_sees_topics(fake):
r = run("-p", fake.port, "provision", "--timeout", "10")
assert r.returncode == 0, r.stdout + r.stderr
assert "244" in r.stdout
def test_info_through_the_library(fake):
pytest.importorskip("rockblock9704")
r = run("-p", fake.port, "info")
assert r.returncode == 0, r.stderr
assert "300434030000000" in r.stdout
def test_send_asks_first(fake):
pytest.importorskip("rockblock9704")
r = subprocess.run([sys.executable, "-m", "rb9704tools.cli", "-p", fake.port, "send",
"--text", "x"], input="n\n", capture_output=True, text=True, timeout=30)
assert "airtime" in (r.stdout + r.stderr).lower()
assert not any(l.startswith("MO ") for l in fake.out.seen)
def test_send_with_yes(fake):
pytest.importorskip("rockblock9704")
r = run("-p", fake.port, "send", "--text", "cli-hello", "--yes")
assert r.returncode == 0, r.stdout + r.stderr
m = fake.out.expect(r"^MO 244 \d+ (\w+)")
assert bytes.fromhex(m.group(1)) == b"cli-hello"
+106
View File
@@ -0,0 +1,106 @@
"""The fake modem on its own, in this process: no serial port, no library."""
import base64
import json
from rb9704tools.fakemodem import FakeModem, crc16_xmodem, with_crc
class Wire:
def __init__(self, topics=(244,)):
self.out = []
self.events = []
self.m = FakeModem(self.out.append, self.events.append, topics)
def put(self, line):
self.m.feed((line + "\r").encode())
def lines(self):
got = [b.decode().rstrip("\r") for b in self.out]
self.out.clear()
return [(int(l.split(" ", 2)[0]), l.split(" ", 2)[1], json.loads(l.split(" ", 2)[2]))
for l in got]
def test_crc_is_xmodem():
assert crc16_xmodem(b"123456789") == 0x31C3
assert with_crc(b"hello") == bytes.fromhex("68656c6c6fc362")
def test_begin_sequence():
w = Wire()
w.put("GET apiVersion {}")
w.put("GET simConfig {}")
w.put("GET operationalState {}")
got = w.lines()
assert [(c, t) for c, t, _ in got] == [(200, "apiVersion"), (200, "simConfig"),
(200, "operationalState")]
assert "active_version" in got[0][2]
assert got[1][2]["interface"] == "internal"
assert got[2][2]["state"] == "active"
def test_put_sim_config_answers_then_reports_status():
w = Wire()
w.put('PUT simConfig {"interface": "internal"}')
assert [(c, t) for c, t, _ in w.lines()] == [(200, "simConfig"), (299, "simStatus")]
def test_mo_in_segments_with_crc():
w = Wire()
w.m.segment = 4
payload = b"hello world"
whole = with_crc(payload)
w.put('PUT messageOriginate {"topic_id":244, "message_length":%d, "request_reference":1}'
% len(whole))
got = w.lines()
assert got[0][:2] == (200, "messageOriginate")
mid = got[0][2]["message_id"]
asks = [got[1]]
while True:
code, target, body = asks[-1]
if target == "messageOriginateStatus":
break
assert (code, target) == (299, "messageOriginateSegment")
s, n = body["segment_start"], body["segment_length"]
w.put('PUT messageOriginateSegment {"topic_id":244, "message_id":%d, "segment_length":%d,'
' "segment_start":%d, "data":"%s"}'
% (mid, n, s, base64.b64encode(whole[s:s + n]).decode()))
asks = [l for l in w.lines() if l[0] == 299]
assert asks[-1][2]["final_mo_status"] == "mo_ack_received"
assert "MO 244 %d %s" % (mid, payload.hex()) in w.events
def test_mo_with_a_bad_crc():
w = Wire()
bad = b"abc\x00\x00"
w.put('PUT messageOriginate {"topic_id":244, "message_length":5, "request_reference":1}')
mid = w.lines()[0][2]["message_id"]
w.put('PUT messageOriginateSegment {"topic_id":244, "message_id":%d, "segment_length":5,'
' "segment_start":0, "data":"%s"}' % (mid, base64.b64encode(bad).decode()))
status = [b for c, t, b in w.lines() if t == "messageOriginateStatus"]
assert status[0]["final_mo_status"] == "crc_error_in_transfer"
assert any(e.startswith("MOBAD 244") for e in w.events)
def test_unprovisioned_topic_is_refused():
w = Wire(topics=())
w.put('PUT messageOriginate {"topic_id":244, "message_length":7, "request_reference":1}')
assert w.lines()[0][:2] == (406, "messageOriginate")
def test_mt_segments_fit_the_library():
w = Wire()
w.m.send_mt(244, b"y" * 3000)
got = w.lines()
segs = [b for c, t, b in got if t == "messageTerminateSegment"]
assert all(len(s["data"]) <= 1446 for s in segs)
data = b"".join(base64.b64decode(s["data"]) for s in segs)
assert data == with_crc(b"y" * 3000)
assert got[-1][2]["final_mt_status"] == "complete"
def test_unknown_target_is_404():
w = Wire()
w.put("GET nonsense {}")
assert w.lines()[0][:2] == (404, "nonsense")
+56
View File
@@ -0,0 +1,56 @@
"""
Ground Control's own Python binding (the rockblock9704 package, the same C
library this repository builds into Zephyr) against the fake modem on a pty.
The fake runs in a process of its own: the binding holds the GIL through
every blocking call.
"""
import time
import pytest
rb = pytest.importorskip("rockblock9704")
@pytest.fixture
def modem(fake):
m = rb.RockBlock9704()
assert m.begin(fake.port)
yield m
m.end()
def poll_until(m, cond, secs=10):
end = time.monotonic() + secs
while time.monotonic() < end:
m.poll()
if cond():
return True
time.sleep(0.01)
return False
def test_identity(modem):
assert modem.get_imei() == "300434030000000"
assert modem.get_iccid() == "8988169712345678901"
assert modem.get_firmware_version() == "v1.2.3"
assert modem.get_signal() == 5
def test_send_async(modem, fake):
done = []
modem.set_mo_message_complete_callback(lambda mid, status: done.append(status))
assert modem.send_message_async(b"from-python", 244)
assert poll_until(modem, lambda: done)
assert done == [1]
m = fake.out.expect(r"^MO 244 \d+ (\w+)")
assert bytes.fromhex(m.group(1)) == b"from-python"
def test_receive_async(modem, fake):
got = []
modem.set_mt_message_complete_callback(lambda mid, status: got.append(status))
fake.cmd("mt 244 to-python")
assert poll_until(modem, lambda: got)
assert modem.receive_message_async() == b"to-python"
assert modem.acknowledge_receive_head_async()
+49
View File
@@ -0,0 +1,49 @@
"""
A real RockBLOCK 9704, with --real-modem PORT. Free checks only: identity
and signal, never a message, so nothing here costs airtime. Indoors the
signal is usually 0, which passes.
"""
import re
import pytest
from conftest import NativeConsole
@pytest.fixture
def real(request):
port = request.config.getoption("--real-modem")
if not port:
pytest.skip("no --real-modem")
return port
def test_library_identity(real):
rb = pytest.importorskip("rockblock9704")
m = rb.RockBlock9704()
assert m.begin(real)
try:
assert re.fullmatch(r"\d{15}", m.get_imei() or "")
assert m.get_card_present()
assert 0 <= m.get_signal() <= 5
finally:
m.end()
def test_zephyr_native_sim_identity(real, request):
exe = request.config.getoption("--zephyr-exe")
if not exe:
pytest.skip("no --zephyr-exe")
c = NativeConsole(exe, real)
try:
c.sh("rb9704 begin")
c.out.expect(r"begin: ok", timeout=20)
c.sh("rb9704 info")
c.out.expect(r"imei\s+\d{15}", timeout=20)
c.sh("rb9704 signal")
c.out.expect(r"signal: [0-5] bars", timeout=20)
c.sh("rb9704 end")
c.out.expect(r"end: ok")
finally:
c.close()
+125
View File
@@ -0,0 +1,125 @@
"""
Zephyr, through the shell sample, against the fake modem: on native_sim
with --zephyr-exe, on a board with --board-console and --board-modem.
"""
import zlib
import pytest
def begin(z):
c, f = z
c.sh("rb9704 begin")
c.out.expect(r"begin: ok")
def test_begin_and_info(zephyr):
c, f = zephyr
begin(zephyr)
c.sh("rb9704 info")
c.out.expect(r"imei\s+300434030000000")
c.out.expect(r"firmware\s+v1\.2\.3")
c.out.expect(r"sim\s+card present, connected")
def test_begin_twice_is_already(zephyr):
c, f = zephyr
begin(zephyr)
c.sh("rb9704 begin")
c.out.expect(r"begin: -\d+ EALREADY")
def test_signal(zephyr):
c, f = zephyr
begin(zephyr)
f.cmd("signal 3")
c.out.expect(r"signal 3 bars")
c.sh("rb9704 signal")
c.out.expect(r"signal: 3 bars")
def test_send_reaches_the_network(zephyr):
c, f = zephyr
begin(zephyr)
c.sh("rb9704 send 244 hello-network")
c.out.expect(r"send: ok")
m = f.out.expect(r"^MO 244 (\d+) (\w+)")
assert bytes.fromhex(m.group(2)) == b"hello-network"
c.out.expect(r"mo done topic 244 id %s result 0" % m.group(1))
def test_send_in_many_segments(zephyr):
c, f = zephyr
begin(zephyr)
f.cmd("segment 16")
text = "".join(chr(ord("a") + i % 26) for i in range(150))
c.sh("rb9704 send 244 " + text)
m = f.out.expect(r"^MO 244 \d+ (\w+)")
assert bytes.fromhex(m.group(1)).decode() == text
c.out.expect(r"result 0")
def test_send_failure_reaches_the_callback(zephyr):
c, f = zephyr
begin(zephyr)
f.cmd("mo-fail network_error")
c.sh("rb9704 send 244 doomed")
c.out.expect(r"mo done topic 244 id \d+ result -\d+")
def test_send_refused_by_the_modem(zephyr):
c, f = zephyr
begin(zephyr)
f.cmd("mo-refuse")
c.sh("rb9704 send 244 refused")
c.out.expect(r"send: -\d+ EIO")
def test_topic_below_64_is_refused_here(zephyr):
c, f = zephyr
begin(zephyr)
c.sh("rb9704 send 10 nope")
c.out.expect(r"send: -\d+ EINVAL")
@pytest.mark.topics("")
def test_provisioning_that_arrives_later_is_seen(zephyr):
"""The library keeps the first answer for good; the module asks again."""
c, f = zephyr
begin(zephyr)
c.sh("rb9704 send 244 too-early")
# EACCES when the library asks first; EIO when it had 244 cached from an
# earlier test on the same boot and the modem refused the message.
c.out.expect(r"send: -\d+ (EACCES|EIO)")
f.cmd("topics 244")
c.sh("rb9704 send 244 now-provisioned")
c.out.expect(r"send: ok")
f.out.expect(r"^MO 244 ")
def test_message_from_the_network(zephyr):
c, f = zephyr
begin(zephyr)
f.cmd("mt 244 hello-buoy")
c.out.expect(r"mt topic 244 id \d+ len 10 crc %08x: hello-buoy$" % zlib.crc32(b"hello-buoy"))
c.sh("rb9704 status")
c.out.expect(r"mt\s+1 received")
def test_long_message_from_the_network(zephyr):
c, f = zephyr
begin(zephyr)
body = "".join(chr(ord("a") + i % 26) for i in range(1500)) # two MT segments
f.cmd("mt 244 " + body)
c.out.expect(r"mt topic 244 id \d+ len 1500 crc %08x: %s$"
% (zlib.crc32(body.encode()), body[:64]))
def test_end(zephyr):
c, f = zephyr
begin(zephyr)
c.sh("rb9704 end")
c.out.expect(r"end: ok")
c.sh("rb9704 send 244 after-end")
c.out.expect(r"send: -\d+ ENOTCONN")