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

+3
View File
@@ -0,0 +1,3 @@
"""Host tools for the RockBLOCK 9704."""
__version__ = "0.1.0"
+644
View File
@@ -0,0 +1,644 @@
"""
rb9704 - drive a RockBLOCK 9704 from the host over its USB-C port.
rb9704 ports
rb9704 [-p PORT] info
rb9704 [-p PORT] signal [--watch S] [--every S]
rb9704 [-p PORT] provision [--timeout S]
rb9704 [-p PORT] jspr [LINE ...]
rb9704 [-p PORT] send (--text T | --hex H | --file F) [--topic N] [--yes]
rb9704 [-p PORT] listen [--out DIR] [--for S]
rb9704 [-p PORT] session (--text T | --hex H | --file F) [--quiet S] [--yes]
rb9704 cloud ping | things | mo | msg ID | mt ... | status ID
The modem side uses Ground Control's rockblock9704 package, a binding of
the same C library this repository builds into Zephyr, so what works here
is the library the firmware runs. `provision` and `jspr` speak
JSPR directly over pyserial instead, because the library only asks for
the provisioning when a message is sent, and asking that way is free.
`send` and `session` cost airtime and ask before sending; --yes skips the
question. Everything else on the modem side is local serial traffic and
costs nothing. `cloud mt` costs airtime too.
The port is -p, or $RB9704_PORT, or the one FTDI FT234XD attached (the
9704's USB-C bridge, 0403:6015). --log FILE appends every event as a JSON
line, for the bench log.
The cloud verbs talk to the Cloudloop Data API (api.cloudloop.com). The
token is $CLOUDLOOP_TOKEN or the first line of ~/.config/rb9704/cloudloop.token;
it never goes in a repository. docs/CLOUDLOOP.md says how to get one.
"""
import argparse
import base64
import datetime as dt
import json
import os
import sys
import time
import urllib.parse
import urllib.request
FTDI_VID, FT234X_PID = 0x0403, 0x6015
BAUD = 230400
RAW_TOPIC = 244
CLOUDLOOP = "https://api.cloudloop.com"
TOKEN_FILE = os.path.expanduser("~/.config/rb9704/cloudloop.token")
LOG = None
def now():
return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
def ev(kind, **kw):
"""Print an event and append it to --log as one JSON line."""
rec = {"t": now(), "ev": kind, **kw}
shown = " ".join(f"{k}={v}" for k, v in kw.items())
print(f"{rec['t']} {kind:<10} {shown}", flush=True)
if LOG:
with open(LOG, "a") as f:
f.write(json.dumps(rec) + "\n")
def die(msg, code=1):
print(f"!! {msg}", file=sys.stderr)
sys.exit(code)
# ---------------------------------------------------------------- ports
def find_ports():
"""Every serial port, with the ones that look like a 9704 first."""
try:
from serial.tools import list_ports
except ImportError:
die("pyserial is missing; run host/rockblock/install.sh")
out = []
for p in list_ports.comports():
is_rb = p.vid == FTDI_VID and p.pid == FT234X_PID
out.append((is_rb, p))
out.sort(key=lambda x: (not x[0], x[1].device))
return out
def pick_port(arg):
if arg:
return arg
if os.environ.get("RB9704_PORT"):
return os.environ["RB9704_PORT"]
rbs = [p for is_rb, p in find_ports() if is_rb]
if len(rbs) == 1:
return rbs[0].device
if not rbs:
die("no FTDI FT234XD attached; plug the 9704's USB-C in, or give -p PORT "
"(rb9704.py ports lists them)")
die("more than one FT234XD attached (" + ", ".join(p.device for p in rbs) +
"); give -p PORT")
def cmd_ports(a):
# the motherboard's ttyS ports have no USB ids and are never the 9704
ports = [x for x in find_ports() if a.all or x[1].vid is not None]
if not ports:
print("no serial ports")
return
for is_rb, p in ports:
ids = f"{p.vid:04x}:{p.pid:04x}" if p.vid is not None else "-"
mark = "9704?" if is_rb else ""
print(f"{p.device:<16} {ids:<10} {mark:<6} {p.serial_number or '':<12} {p.description}")
if os.path.isdir("/dev/serial/by-id"):
for n in sorted(os.listdir("/dev/serial/by-id")):
if "FT234X" in n:
print(f" by-id: /dev/serial/by-id/{n}")
# ---------------------------------------------------------------- library
def open_rb(port):
try:
from rockblock9704 import RockBlock9704
except ImportError:
die("rockblock9704 is missing; run host/rockblock/install.sh")
rb = RockBlock9704()
if not rb.begin(port):
die(f"begin({port}) failed: wrong port, modem not booted (green RDY "
"LED), or another program holds the port (ModemManager, a "
"terminal). docs/CLOUDLOOP.md, \"When begin fails\"")
return rb
def cmd_info(a):
port = pick_port(a.port)
rb = open_rb(port)
try:
info = {
"port": port,
"hardware": rb.get_hardware_version(),
"firmware": rb.get_firmware_version(),
"serial": rb.get_serial_number(),
"imei": rb.get_imei(),
"iccid": rb.get_iccid(),
"card_present": rb.get_card_present(),
"sim_connected": rb.get_sim_connected(),
"board_temp_c": rb.get_board_temp(),
"signal": rb.get_signal(),
}
finally:
rb.end()
if a.json:
print(json.dumps(info, indent=2))
else:
for k, v in info.items():
print(f"{k:<14} {v}")
if LOG:
ev("info", **info)
def cmd_signal(a):
port = pick_port(a.port)
rb = open_rb(port)
try:
if not a.watch:
ev("signal", bars=rb.get_signal())
return
end = time.monotonic() + a.watch
seen = {}
while time.monotonic() < end:
bars = rb.get_signal()
seen[bars] = seen.get(bars, 0) + 1
ev("signal", bars=bars)
time.sleep(a.every)
total = sum(seen.values())
ev("summary", samples=total,
**{f"bars{k}": f"{100 * v / total:.0f}%" for k, v in sorted(seen.items())})
except KeyboardInterrupt:
pass
finally:
rb.end()
def payload(a):
if a.text is not None:
return a.text.encode()
if a.hex is not None:
return bytes.fromhex(a.hex)
with open(a.file, "rb") as f:
return f.read()
def confirm(data, topic, yes):
print(f"AIRTIME: {len(data)} bytes to topic {topic}. This costs money.", flush=True)
if yes:
return
if not sys.stdin.isatty():
die("not a terminal; pass --yes to send")
if input("send? [y/N] ").strip().lower() not in ("y", "yes"):
die("not sent", 0)
def cmd_send(a):
data = payload(a)
port = pick_port(a.port)
confirm(data, a.topic, a.yes)
rb = open_rb(port)
try:
ev("signal", bars=rb.get_signal())
t0 = time.monotonic()
ok = rb.send_message(data, topic=None if a.topic == RAW_TOPIC else a.topic,
timeout=a.timeout)
secs = time.monotonic() - t0
ev("mo", ok=ok, bytes=len(data), topic=a.topic, secs=f"{secs:.1f}")
if not ok and secs < 2:
# the library checks provisioning before it queues anything, and
# gives up at once if the topic is not there: nothing was sent
print(f" refused before transmitting: the modem holds no "
f"provisioning for topic {a.topic}. Run provision under sky.")
finally:
rb.end()
sys.exit(0 if ok else 2)
class Async:
"""The library's callback API, polled from one loop. The callbacks only
record; all the work is done in the loop, between polls."""
def __init__(self, port):
self.rb = None
self.mo_done = None
self.mo_id = None
self.mt_ready = 0
self.bars = None
self.port = port
def start(self):
from rockblock9704 import RockBlock9704
rb = RockBlock9704()
rb.set_message_provisioning_callback(self._prov)
rb.set_mo_message_started_callback(self._mo_started)
rb.set_mo_message_complete_callback(self._mo)
rb.set_mt_message_complete_callback(self._mt)
rb.set_constellation_state_callback(self._sig)
if not rb.begin(self.port):
die(f"begin({self.port}) failed")
time.sleep(0.1) # the library asks for 100 ms before the first queue
self.rb = rb
return rb
def _prov(self, p):
ev("prov", set=p["provisioningSet"], topics=",".join(
f"{t['topicId']}:{t['topicName']}" for t in p["provisioning"]))
def _mo_started(self, mid):
self.mo_id = mid
ev("mo_start", id=mid)
def _mo(self, mid, status):
self.mo_done = status == 1
ev("mo", id=mid, ok=self.mo_done)
def _mt(self, mid, status):
ev("mt_done", id=mid, ok=status == 1)
if status == 1:
self.mt_ready += 1
def _sig(self, s):
if s["signalBars"] != self.bars:
self.bars = s["signalBars"]
ev("signal", bars=self.bars, level=s["signalLevel"],
visible=s["constellationVisible"])
def drain(self, out):
"""Collect every completed MT. Returns how many."""
n = 0
while self.mt_ready:
msg = self.rb.receive_message_async()
if msg is None:
break
self.rb.acknowledge_receive_head_async()
self.mt_ready -= 1
n += 1
name = None
if out:
os.makedirs(out, exist_ok=True)
name = os.path.join(out, dt.datetime.now(dt.timezone.utc)
.strftime("MT-%Y%m%dT%H%M%S.%fZ.bin"))
with open(name, "wb") as f:
f.write(msg)
ev("mt", bytes=len(msg), head=msg[:24].hex(), file=name or "-")
return n
def cmd_listen(a):
s = Async(pick_port(a.port))
rb = s.start()
end = time.monotonic() + a.for_ if a.for_ else None
got = 0
ev("listen", port=s.port, out=a.out or "-")
try:
while end is None or time.monotonic() < end:
rb.poll()
got += s.drain(a.out)
time.sleep(0.01)
except KeyboardInterrupt:
pass
finally:
rb.end()
ev("summary", mt=got)
def cmd_session(a):
"""One MO, then listen until a.quiet seconds pass with no new MT. This is
the 9704 session designed in docs/PLAN.md, "The session", run from the
host so the quiet window can be chosen from what is seen."""
data = payload(a)
port = pick_port(a.port)
confirm(data, a.topic, a.yes)
s = Async(port)
rb = s.start()
got = 0
try:
if not rb.send_message_async(data, a.topic):
die("the modem would not queue the message (not provisioned for "
f"topic {a.topic}? run provision)")
ev("mo_queue", bytes=len(data), topic=a.topic)
t0 = time.monotonic()
while s.mo_done is None:
rb.poll()
got += s.drain(a.out)
if time.monotonic() - t0 > a.timeout:
if s.mo_id is not None:
rb.cancel_message(a.topic, s.mo_id)
ev("mo", ok=False, why="timeout", secs=a.timeout)
break
time.sleep(0.01)
ev("mo_secs", secs=f"{time.monotonic() - t0:.1f}")
last = time.monotonic()
while time.monotonic() - last < a.quiet:
rb.poll()
n = s.drain(a.out)
if n:
got += n
last = time.monotonic()
time.sleep(0.01)
except KeyboardInterrupt:
pass
finally:
rb.end()
ev("summary", mo_ok=s.mo_done, mt=got, quiet=a.quiet)
sys.exit(0 if s.mo_done else 2)
# ---------------------------------------------------------------- raw JSPR
class Jspr:
"""JSPR by hand: one line out, `<code> <target> <json>` lines back, all
ending in CR. 299 is unsolicited. The same setup as the library's
rbBegin: an active API version, the internal SIM, state active."""
def __init__(self, port):
import serial
self.s = serial.Serial(port, BAUD, timeout=0.1)
self.buf = b""
self.s.reset_input_buffer()
def close(self):
self.s.close()
def lines(self, secs):
end = time.monotonic() + secs
while time.monotonic() < end:
self.buf += self.s.read(4096)
while b"\r" in self.buf:
line, self.buf = self.buf.split(b"\r", 1)
# bootInfo can arrive behind a DC1; the library strips it too
line = line.lstrip(b"\x11\n ").decode(errors="replace")
if len(line) >= 4:
yield parse(line)
def cmd(self, line, target=None, secs=3.0, show=False):
"""Send a line, return the first response for its target. Anything
else that arrives meanwhile is shown as it goes past."""
target = target or line.split()[1]
self.s.write((line + "\r").encode())
for code, tgt, js, raw in self.lines(secs):
if show or tgt != target or code == 299:
print(f" < {raw}")
if tgt == target and code != 299:
return code, js
return None, None
def setup(self):
code, js = self.cmd("GET apiVersion {}")
if code != 200:
die(f"GET apiVersion answered {code}: is this a 9704 at {BAUD}?")
if not js.get("active_version"):
v = js["supported_versions"][0]
self.cmd("PUT apiVersion " + json.dumps({"active_version": v}))
code, js = self.cmd("GET simConfig {}")
if js.get("interface") != "internal":
self.cmd('PUT simConfig {"interface": "internal"}')
code, js = self.cmd("GET operationalState {}")
st = js.get("state")
if st != "active":
if st != "inactive":
self.cmd('PUT operationalState {"state": "inactive"}')
self.cmd('PUT operationalState {"state": "active"}')
def parse(line):
code, _, rest = line.partition(" ")
tgt, _, js = rest.partition(" ")
try:
return int(code), tgt, json.loads(js) if js else {}, line
except ValueError:
return 0, "", {}, line
def cmd_provision(a):
port = pick_port(a.port)
j = Jspr(port)
t0 = time.monotonic()
try:
j.setup()
while True:
code, sig = j.cmd("GET constellationState {}")
code, prov = j.cmd("GET messageProvisioning {}")
topics = (prov or {}).get("provisioning") or []
ev("prov", code=code, set=bool(topics),
bars=(sig or {}).get("signal_bars"),
visible=(sig or {}).get("constellation_visible"),
topics=",".join(f"{t.get('topic_id')}:{t.get('topic_name')}"
for t in topics) or "-")
if topics:
print(json.dumps(prov, indent=2))
return
if time.monotonic() - t0 > a.timeout:
die(f"not provisioned after {a.timeout} s; a clear sky view "
"for 10 to 30 minutes is what it needs", 2)
for _ in j.lines(a.every):
pass
except KeyboardInterrupt:
pass
finally:
j.close()
def cmd_jspr(a):
"""Lines from the arguments, or typed, one at a time. Responses and
anything unsolicited are printed as they arrive."""
j = Jspr(pick_port(a.port))
try:
if a.setup:
j.setup()
if a.line:
for line in a.line:
print(f" > {line}")
j.cmd(line, show=True, secs=a.wait)
return
print("JSPR at %d. A line such as: GET hwInfo {} (ctrl-d to quit)" % BAUD)
while True:
try:
line = input("> ").strip()
except EOFError:
return
if line:
j.cmd(line, show=True, secs=a.wait)
else:
for _, _, _, raw in j.lines(1.0):
print(f" < {raw}")
except KeyboardInterrupt:
pass
finally:
j.close()
# ---------------------------------------------------------------- Cloudloop
def token():
t = os.environ.get("CLOUDLOOP_TOKEN")
if not t and os.path.exists(TOKEN_FILE):
with open(TOKEN_FILE) as f:
t = f.readline().strip()
if not t:
die(f"no Cloudloop token: set CLOUDLOOP_TOKEN or write {TOKEN_FILE} "
"(docs/CLOUDLOOP.md, \"The API token\")")
return t
def api(path, **params):
"""Every Cloudloop call is a POST with its arguments, the token among
them, in the query string."""
q = urllib.parse.urlencode({"token": token(), **params})
req = urllib.request.Request(f"{CLOUDLOOP}{path}?{q}", data=b"", method="POST")
with urllib.request.urlopen(req, timeout=30) as r:
body = json.load(r)
for k in ("DispatcherException", "DispatcherValidationException"):
if k in body:
die(f"{path}: {json.dumps(body[k])}")
return body
def thing(a):
t = a.thing or os.environ.get("CLOUDLOOP_THING")
if not t:
die("which thing? --thing ID or $CLOUDLOOP_THING (cloud things lists them)")
return t
def stamp(d):
return d.strftime("%Y-%m-%d %H:%M:%S")
def cmd_cloud(a):
if a.verb == "ping":
print(json.dumps(api("/Platform/Ping")))
elif a.verb == "things":
for t in api("/Data/GetThings").get("things", []):
print(f"{t.get('id')} certus={t.get('subscriberCertus') or '-'} "
f"sbd={t.get('subscriberSbd') or '-'} {t.get('name', '')}")
elif a.verb == "mo":
to = dt.datetime.now(dt.timezone.utc)
frm = to - dt.timedelta(hours=a.hours)
recs = api("/Data/GetMessageRecordsForThing", thing=thing(a),
**{"from": stamp(frm), "to": stamp(to)}).get("messageRecords", [])
for r in recs:
if a.json:
print(json.dumps(r))
else:
print(f"{r.get('id')} {r.get('receivedAt', r.get('createdAt', ''))} "
f"{r.get('type')} {r.get('direction', '')} "
f"{(r.get('snippet') or '')[:40]}")
print(f"{len(recs)} record(s) in the last {a.hours} h", file=sys.stderr)
elif a.verb == "msg":
print(json.dumps(api("/Data/GetMessageRecord", messageRecord=a.id), indent=2))
print(json.dumps(api("/Data/GetLingoMo", messageRecord=a.id), indent=2))
elif a.verb == "status":
print(json.dumps(api("/Data/GetMtDeliveryStatus", message=a.id)))
elif a.verb == "mt":
data = payload(a)
token()
confirm(data, a.topic, a.yes)
r = api("/Data/DoSendImtMessage", thing=thing(a), topic=a.topic,
ringStyle=a.ring, message=base64.b64encode(data).decode())
ev("cloud_mt", bytes=len(data), topic=a.topic, result=json.dumps(r))
# ---------------------------------------------------------------- main
def add_payload(p, required=True):
g = p.add_mutually_exclusive_group(required=required)
g.add_argument("--text", help="the message as UTF-8 text")
g.add_argument("--hex", help="the message as hex bytes")
g.add_argument("--file", help="the message is this file's bytes")
p.add_argument("--yes", action="store_true", help="send without asking")
def main():
global LOG
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0].strip(),
formatter_class=argparse.RawDescriptionHelpFormatter)
# -p and --log go before or after the verb; SUPPRESS keeps a verb's
# default from hiding a value given before it
common = argparse.ArgumentParser(add_help=False)
common.add_argument("-p", "--port", default=argparse.SUPPRESS,
help="serial port (default: $RB9704_PORT or the FT234XD)")
common.add_argument("--log", default=argparse.SUPPRESS,
help="append every event to this file as JSON lines")
ap.add_argument("-p", "--port", help=argparse.SUPPRESS)
ap.add_argument("--log", help=argparse.SUPPRESS)
sub = ap.add_subparsers(dest="cmd", required=True)
real_add = sub.add_parser
sub.add_parser = lambda *x, **k: real_add(*x, parents=[common], **k)
p = sub.add_parser("ports", help="list USB serial ports, the 9704 first")
p.add_argument("--all", action="store_true", help="the ttyS ports too")
p.set_defaults(fn=cmd_ports)
p = sub.add_parser("info", help="identity, SIM and signal (free)")
p.add_argument("--json", action="store_true")
p.set_defaults(fn=cmd_info)
p = sub.add_parser("signal", help="signal bars 0-5 (free)")
p.add_argument("--watch", type=float, default=0, help="keep reading for S seconds")
p.add_argument("--every", type=float, default=5, help="seconds between reads")
p.set_defaults(fn=cmd_signal)
p = sub.add_parser("provision", help="wait until the modem holds its topics (free)")
p.add_argument("--timeout", type=float, default=3600)
p.add_argument("--every", type=float, default=30)
p.set_defaults(fn=cmd_provision)
p = sub.add_parser("jspr", help="raw JSPR lines, typed or given")
p.add_argument("line", nargs="*")
p.add_argument("--setup", action="store_true",
help="set the API version, SIM and state first, as begin does")
p.add_argument("--wait", type=float, default=3, help="seconds to wait for each answer")
p.set_defaults(fn=cmd_jspr)
p = sub.add_parser("send", help="one MO message, blocking (COSTS AIRTIME)")
add_payload(p)
p.add_argument("--topic", type=int, default=RAW_TOPIC)
p.add_argument("--timeout", type=int, default=120)
p.set_defaults(fn=cmd_send)
p = sub.add_parser("listen", help="collect MT messages as they arrive (free)")
p.add_argument("--out", help="write each MT here as MT-<utc>.bin")
p.add_argument("--for", dest="for_", type=float, default=0, help="stop after S seconds")
p.set_defaults(fn=cmd_listen)
p = sub.add_parser("session", help="one MO, then MT until a quiet window (COSTS AIRTIME)")
add_payload(p)
p.add_argument("--topic", type=int, default=RAW_TOPIC)
p.add_argument("--timeout", type=int, default=180, help="give up on the MO after S seconds")
p.add_argument("--quiet", type=float, default=60, help="end after S seconds with no new MT")
p.add_argument("--out", help="write each MT here as MT-<utc>.bin")
p.set_defaults(fn=cmd_session)
p = sub.add_parser("cloud", help="the Cloudloop Data API")
cs = p.add_subparsers(dest="verb", required=True)
cs.add_parser("ping")
cs.add_parser("things")
q = cs.add_parser("mo", help="message records for a thing")
q.add_argument("--thing")
q.add_argument("--hours", type=float, default=24)
q.add_argument("--json", action="store_true")
q = cs.add_parser("msg", help="one message record and its Lingo form")
q.add_argument("id")
q = cs.add_parser("status", help="delivery status of an MT")
q.add_argument("id")
q = cs.add_parser("mt", help="queue an MT to the modem (COSTS AIRTIME)")
q.add_argument("--thing")
q.add_argument("--topic", default="IMT_TOPIC_RAW")
q.add_argument("--ring", default="RING_STYLE_NORMAL")
add_payload(q)
p.set_defaults(fn=cmd_cloud)
a = ap.parse_args()
LOG = a.log
a.fn(a)
if __name__ == "__main__":
main()
+381
View File
@@ -0,0 +1,381 @@
"""
rb9704-fake - a fake RockBLOCK 9704: JSPR over a serial port, no satellites.
rb9704-fake make a pty, print its path, serve it
rb9704-fake --port /dev/ttyUSB0 serve a real serial port instead (a
USB-serial adapter wired to a board's
modem pins)
It answers what Ground Control's library asks (docs/JSPR.md is the
protocol, taken from the library's source), takes MO messages segment by
segment and checks their CRC, and sends MT messages, signal and
provisioning when told to. One fake serves three kinds of test: the
library's own Python binding on the host, Zephyr on native_sim, and Zephyr
on a board through a USB-serial adapter.
Control is one command per line on stdin, events one per line on stdout:
mt <topic> <text> send an MT
mthex <topic> <hex> send an MT, bytes as hex
signal <bars> [hidden] send a constellationState, 0 to 5 bars
topics <id>[,<id>...] the provisioning from now on (empty: none)
mo-fail <status> the next MO ends with this final_mo_status
mo-refuse the next PUT messageOriginate is refused (406)
segment <n> ask for MO segments of n bytes (default 1446)
state <state> the operationalState from now on
quit
READY <port>
RX <line> every request from the host, as received
TX <line> every line sent to the host
MO <topic> <id> <hex> a whole MO with a good CRC
MOBAD <topic> <id> <hex> a whole MO whose CRC did not match
MT <topic> <id> an MT sent
Run it in a process of its own beside Ground Control's Python binding: the
binding holds the GIL through every blocking call, so a fake in a thread of
the same process would freeze while the library waits on it.
"""
import argparse
import base64
import json
import os
import select
import sys
import threading
import time
import tty
MO_SEGMENT_MAX = 1446
MT_SEGMENT_MAX = 1083 # the library cuts MT data at 1446 base64 characters
def crc16_xmodem(data):
crc = 0
for b in data:
crc ^= b << 8
for _ in range(8):
crc = ((crc << 1) ^ 0x1021) if crc & 0x8000 else (crc << 1)
crc &= 0xFFFF
return crc
def with_crc(data):
c = crc16_xmodem(data)
return bytes(data) + bytes([c >> 8, c & 0xFF])
class FakeModem:
"""The modem's half of JSPR. Feed it bytes from the host with
`feed()`; it writes replies through `write`, a function taking bytes.
`events` is called with one string per event. Thread-safe: the control
methods may be called from another thread than the one feeding."""
def __init__(self, write, events=print, topics=(244,)):
self.write = write
self.events = events
self.lock = threading.RLock()
self.buf = b""
self.topics = list(topics)
self.state = "active"
self.sim_interface = "internal"
self.api_active = True
self.bars, self.visible = 5, True
self.next_id = 1
self.segment = MO_SEGMENT_MAX
self.mo = None # the MO being received
self.mo_fail = None # final status for the next MO
self.mo_refuse = False
self.imei = "300434030000000"
self.serial = "123456"
self.iccid = "8988169712345678901"
# --- the wire -------------------------------------------------------
def send(self, code, target, obj):
line = "%d %s %s" % (code, target, json.dumps(obj, separators=(",", ":")))
self.events("TX " + line)
self.write((line + "\r").encode())
def feed(self, data):
with self.lock:
self.buf += data
while True:
i = self.buf.find(b"\r")
if i < 0:
# The library ends lines with CR; tolerate LF from a person.
i = self.buf.find(b"\n")
if i < 0:
return
line, self.buf = self.buf[:i], self.buf[i + 1:]
line = line.strip(b"\r\n").decode(errors="replace")
if line:
self.request(line)
def request(self, line):
self.events("RX " + line)
parts = line.split(" ", 2)
if len(parts) < 2:
return
verb, target = parts[0], parts[1]
if verb not in ("GET", "PUT"):
return # boot chatter from whatever is on the other end of the wire
try:
body = json.loads(parts[2]) if len(parts) > 2 else {}
except ValueError:
self.send(407, target, {})
return
handler = getattr(self, "on_%s_%s" % (verb.lower(), target), None)
if handler is None:
self.send(404, target, {})
return
handler(body)
# --- requests -------------------------------------------------------
def on_get_apiVersion(self, body):
v = {"major": 1, "minor": 0, "patch": 0}
reply = {"supported_versions": [v]}
if self.api_active:
reply["active_version"] = v
self.send(200, "apiVersion", reply)
def on_put_apiVersion(self, body):
self.api_active = True
self.send(200, "apiVersion", {"active_version": body.get("active_version", {})})
def on_get_simConfig(self, body):
self.send(200, "simConfig", {"interface": self.sim_interface})
def on_put_simConfig(self, body):
self.sim_interface = body.get("interface", "internal")
self.send(200, "simConfig", {"interface": self.sim_interface})
self.send(299, "simStatus", self.sim_status())
def sim_status(self):
return {"card_present": True, "sim_connected": True, "iccid": self.iccid}
def on_get_simStatus(self, body):
self.send(200, "simStatus", self.sim_status())
def on_get_operationalState(self, body):
self.send(200, "operationalState", {"state": self.state, "reason": 0})
def on_put_operationalState(self, body):
self.state = body.get("state", self.state)
self.send(200, "operationalState", {"state": self.state})
self.send(299, "operationalState", {"state": self.state, "reason": 0})
def on_put_serviceConfig(self, body):
self.send(200, "serviceConfig", {})
def on_get_hwInfo(self, body):
self.send(200, "hwInfo", {"hw_version": "1.0", "serial_number": self.serial,
"imei": self.imei, "board_temp": 27})
def on_get_firmware(self, body):
self.send(200, "firmware", {"slot": body.get("slot", "primary"), "validity": 1,
"version": {"major": 1, "minor": 2, "patch": 3,
"build_info": "fake"},
"hash": "0" * 64})
def constellation(self):
return {"constellation_visible": self.visible, "signal_bars": self.bars,
"signal_level": -100 - (5 - self.bars) * 5}
def on_get_constellationState(self, body):
self.send(200, "constellationState", self.constellation())
def on_get_messageProvisioning(self, body):
self.send(200, "messageProvisioning", {"provisioning": [
{"topic_id": t, "topic_name": "RAW" if t == 244 else "TOPIC%d" % t,
"priority": "Low"} for t in self.topics]})
def on_put_messageOriginate(self, body):
topic, length = body.get("topic_id", 0), body.get("message_length", 0)
if self.mo_refuse or topic not in self.topics or length < 3:
self.mo_refuse = False
self.send(406, "messageOriginate", {})
return
mid = self.take_id()
self.mo = {"topic": topic, "id": mid, "len": length, "data": bytearray(length),
"next": 0}
self.send(200, "messageOriginate", {"topic_id": topic,
"request_reference": body.get("request_reference", 1),
"message_id": mid,
"message_response": "message_accepted"})
self.ask_segment()
def ask_segment(self):
mo = self.mo
n = min(self.segment, mo["len"] - mo["next"])
self.send(299, "messageOriginateSegment", {"topic_id": mo["topic"], "message_id": mo["id"],
"segment_length": n,
"segment_start": mo["next"]})
def on_put_messageOriginateSegment(self, body):
mo = self.mo
if mo is None or body.get("message_id") != mo["id"]:
self.send(405, "messageOriginateSegment", {"message_id": body.get("message_id", 0)})
return
start, n = body.get("segment_start", 0), body.get("segment_length", 0)
data = base64.b64decode(body.get("data", ""))
mo["data"][start:start + len(data)] = data
mo["next"] = start + n
self.send(200, "messageOriginateSegment", {"topic_id": mo["topic"], "message_id": mo["id"]})
if mo["next"] < mo["len"]:
self.ask_segment()
return
whole, payload = bytes(mo["data"]), bytes(mo["data"][:-2])
good = with_crc(payload) == whole
self.events("%s %d %d %s" % ("MO" if good else "MOBAD", mo["topic"], mo["id"],
payload.hex()))
status = self.mo_fail or ("mo_ack_received" if good else "crc_error_in_transfer")
self.mo_fail = None
self.mo = None
self.send(299, "messageOriginateStatus", {"topic_id": mo["topic"], "message_id": mo["id"],
"final_mo_status": status})
def on_put_messageOriginateStatus(self, body):
# Cancel: the library does not read the reply, only the status after.
self.send(200, "messageOriginateStatus", {})
if self.mo is not None and body.get("action") == "cancel":
mo, self.mo = self.mo, None
self.send(299, "messageOriginateStatus", {"topic_id": mo["topic"],
"message_id": mo["id"],
"final_mo_status":
"message_cancelled_pre_transit"})
# --- what the network does --------------------------------------------
def take_id(self):
mid = self.next_id
self.next_id = self.next_id % 255 + 1
return mid
def send_mt(self, topic, payload):
with self.lock:
whole = with_crc(payload)
mid = self.take_id()
self.send(299, "messageTerminate", {"topic_id": topic, "message_id": mid,
"message_length_max": len(whole)})
for start in range(0, len(whole), MT_SEGMENT_MAX):
seg = whole[start:start + MT_SEGMENT_MAX]
self.send(299, "messageTerminateSegment", {
"topic_id": topic, "message_id": mid, "segment_length": len(seg),
"segment_start": start, "data": base64.b64encode(seg).decode()})
self.send(299, "messageTerminateStatus", {"topic_id": topic, "message_id": mid,
"final_mt_status": "complete"})
self.events("MT %d %d" % (topic, mid))
def set_signal(self, bars, visible=True):
with self.lock:
self.bars, self.visible = bars, visible
self.send(299, "constellationState", self.constellation())
def control(self, line):
"""One stdin command. Returns False on quit."""
w = line.split(" ", 2)
cmd = w[0] if w else ""
if cmd == "mt":
self.send_mt(int(w[1]), w[2].encode() if len(w) > 2 else b"")
elif cmd == "mthex":
self.send_mt(int(w[1]), bytes.fromhex(w[2]))
elif cmd == "signal":
self.set_signal(int(w[1]), not (len(w) > 2 and w[2] == "hidden"))
elif cmd == "topics":
with self.lock:
self.topics = [int(t) for t in w[1].split(",")] if len(w) > 1 and w[1] else []
elif cmd == "mo-fail":
self.mo_fail = w[1]
elif cmd == "mo-refuse":
self.mo_refuse = True
elif cmd == "segment":
self.segment = max(1, min(MO_SEGMENT_MAX, int(w[1])))
elif cmd == "state":
self.state = w[1]
elif cmd == "quit":
return False
elif cmd:
self.events("ERR unknown command " + cmd)
return True
# --- serving a port -----------------------------------------------------------
def open_pty():
master, slave = os.openpty()
tty.setraw(slave)
path = os.ttyname(slave)
# Keep the slave open ourselves so the master never sees EIO while the
# host has it closed (the library opens, closes and opens it again).
return master, slave, path
def serve(fd, modem, stop):
while not stop.is_set():
r, _, _ = select.select([fd], [], [], 0.1)
if not r:
continue
try:
data = os.read(fd, 4096)
except OSError:
time.sleep(0.05) # EIO: no reader on the other side yet
continue
if data:
modem.feed(data)
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0].strip())
ap.add_argument("--port", help="a real serial port instead of a pty")
ap.add_argument("--baud", type=int, default=230400)
ap.add_argument("--topics", default="244", help="provisioned topics, comma separated")
ap.add_argument("--quiet", action="store_true", help="no RX/TX lines on stdout")
ap.add_argument("--stay", action="store_true",
help="keep serving when stdin closes, until interrupted")
a = ap.parse_args(argv)
out_lock = threading.Lock()
def events(s):
if a.quiet and (s.startswith("RX ") or s.startswith("TX ")):
return
with out_lock:
sys.stdout.write(s + "\n")
sys.stdout.flush()
if a.port:
import serial
port = serial.Serial(a.port, a.baud, timeout=0)
fd, path, keep = port.fileno(), a.port, port
else:
fd, keep, path = open_pty()
def write(b):
os.write(fd, b)
topics = [int(t) for t in a.topics.split(",") if t]
modem = FakeModem(write, events, topics)
stop = threading.Event()
t = threading.Thread(target=serve, args=(fd, modem, stop), daemon=True)
t.start()
events("READY " + path)
try:
for line in sys.stdin:
if not modem.control(line.strip()):
break
else:
while a.stay:
time.sleep(3600)
except KeyboardInterrupt:
pass
stop.set()
del keep
return 0
if __name__ == "__main__":
sys.exit(main())