README says what the module adds to Ground Control's library and what it works around, and how to use it. JSPR.md is the protocol as the library speaks it, from its source, with its surprises. CLOUDLOOP.md is account to first message, from WII6's IRIDIUM.md. TESTING.md is the levels, the rig, and what has passed: Python, native_sim, an ESP32-S3 and an STM32U5 against the fake, and free checks against a real 9704.
42 KiB
JSPR as Ground Control's library speaks it
JSPR is the RockBLOCK 9704's serial protocol: JSON lines at 230400 8N1.
This is what Ground Control's C library (v1.0.1, commit ab37e8a, the pin
in ../west.yml) sends and what it expects back, taken from its source
with a file and line for each claim. Paths are relative to that library's
repository. It is what python/rb9704tools/fakemodem.py implements, and
section 8 is the list of the library's surprises, several of which this
module works around (README, "Ground Control's library").
It describes the library, not the modem: where the two might differ (what a real modem sends unprompted, how large its MT segments are) the source cannot say, and the text says so. Field names the library never reads are marked as placeholders.
"Library" means the C library. The Python binding (rockblock9704/rockblock.c,
rockblock9704/rockblock9704.py) is a thin wrapper over the same C files; it
is built without KERMIT (CMakeLists.txt:88-109), so firmware update is not
in it.
Notation: >> is a line the library writes to the modem, << a line the modem
(or a fake) writes. \r is shown where it matters.
1. Line framing
1.1 Requests (library to modem)
- Format:
<VERB> <target> <json>\r. VERB isGETorPUT, one space each side of the target, JSON always present ({}for a bare GET). - Terminator is a single CR, no LF. Every format string ends
\r(src/jspr_command.c:24,40,89,106,166,184,207,236,257,272,286,325,350,370,386,407). - Built with
snprintfinto a 2048-byte buffer (src/jspr_command.h:12,src/jspr_command.c:8). The spacing inside the JSON is not uniform between commands (some"k": v, some"k":v), so parse the JSON, do not string-match it. - Written with one
write()loop until the whole line has gone (src/serial_presets/serial_linux/serial_linux.c:188-228).
1.2 Responses and unsolicited lines (modem to library)
Format: <3-digit code> <target> <json>\r
Reader: receiveJspr() (src/jspr.c:35-160).
- Reads one byte at a time (
src/jspr.c:59). Each byte read is aselect()with a 0.5 s timeout (serial_linux.c:153-166). If no byte arrives within 0.5 s the read returns -1 andreceiveJsprgives up for this call (src/jspr.c:60-64). So:- the first byte of a reply must arrive within 0.5 s of the request;
- a pause of 0.5 s or more inside a line loses the bytes already read (they were consumed and the call returns false). Write each line in one go.
- Terminator is CR (
'\r'), and only when it is at index > 2 of the line (src/jspr.c:65). LF is not a terminator and is not stripped at the end; a trailing LF becomes the first byte of the next line, where the code-scan below skips it (atoi skips leading whitespace). Safest: end every line with\ronly.\r\nalso works in practice. - Lines shorter than 9 bytes before the CR (
JSPR_MIN_RESPONSE,src/jspr.h:18, test atsrc/jspr.c:85) are not parsed and not discarded:posis not reset, so the next byte overwrites the NUL and the short line is glued onto the front of the next one. Do not send short lines. - Maximum line: 8191 bytes (
RX_BUFFER_SIZE 8192,src/jspr.h:13,src/jspr.c:57,74). A line that fills the buffer without CR ends the call. - Code: the reader scans 3-character windows from the start of the line,
atoi()on each, until it finds a value in 200..500 inclusive (src/jspr.c:89-108); leading junk (the source mentions a DC1 beforebootInfo,src/jspr.c:87-88) is then cut off withmemmove(src/jspr.c:110-113). The code window is the first 3 bytes after that. - Target: from byte 4 (after
NNN) up to the next space (src/jspr.c:115-127). No space after the target means the call returns false (src/jspr.c:117-121). The target is copied only if shorter than 30 characters (JSPR_MAX_TARGET_LENGTH,src/jspr.h:16,src/jspr.c:123). - JSON: from the first
{after the code to the end of the line (src/jspr.c:140-151). No{means the call returns false. JSON of 3500 characters or more (JSPR_MAX_JSON_LENGTH,src/jspr.h:20) is left empty while the call still returns true, so the parse that follows fails. Always send a JSON object,{}at minimum.
1.3 Matching a response to a request
There is no request id on the wire. Matching is by target name only:
receiveJspr(&response, "target"): every complete line whose target is not exactlytargetis thrown away and reading continues (src/jspr.c:129-138). The first line with the right target is returned, whatever its code. The caller then checks the code (almost always== 200).waitForJsprMessage(&response, target, code, seconds)(src/jspr.c:162-188): callsreceiveJsprwith the target filter in a loop until a line has both the target and the code, or the timeout passes (checked after eachreceiveJspr, thendelay(10)). Lines with the right target and the wrong code are consumed and lost.receiveJspr(&response, NULL)(only fromrbPoll,src/rockblock_9704.c:716): accepts any target, returns one line.
Consequence for a fake: while the library is waiting on a reply to a request,
any other line you send (including 299 messageTerminate, 299 messageOriginateSegment, 299 constellationState) is silently discarded.
Send the solicited 200 line first and unsolicited lines after it.
1.4 Unsolicited lines
Recognised by code 299 (JSPR_RC_UNSOLICITED_MESSAGE, src/jspr.h:40)
and the target, in rbPoll() (src/rockblock_9704.c:704-906). rbPoll does
anything only if serialPeek() (FIONREAD, serial_linux.c:230-241)
reports bytes waiting (src/rockblock_9704.c:714), and then reads one
line per call. Targets it acts on:
| Code | Target | Condition | Section |
|---|---|---|---|
| 299 | messageOriginateSegment |
an MO is at the head of the queue | 4 |
| not 200, not 299 | messageOriginateSegment |
an MO is at the head | 4 |
| 299 | messageOriginateStatus |
an MO is at the head | 4 |
| 299 | messageTerminate |
always | 5 |
| 299 | messageTerminateSegment |
always | 5 |
| 299 | messageTerminateStatus |
always | 5 |
| 299 | constellationState |
always | 6 |
Every other line read by rbPoll (any 200 reply, 299 operationalState,
299 simStatus, 299 messageProvisioning, 299 bootInfo, ...) is consumed
and ignored.
1.5 Response codes
src/jspr.h:37-63. Only 200 means success anywhere in the library; 299 is
unsolicited. Error codes the enum names: 400 api version not selected,
401 unsupported request type, 402 configuration already set, 403 command too
long, 404 unknown target, 405 command malformed, 406 operation not allowed,
407 bad JSON, 408 request failed, 409 unauthorised, 410 SIM not configured,
411 wake xcvr in invalid, 412 invalid channel, 413 invalid action,
414 hardware not configured, 415 invalid radio path, 416 crash dump not
available, 417 feature not supported by hardware, 418 not provisioned,
419 invalid transmit power, 420 invalid burst type, 500 serial port error.
None of them is handled individually; any non-200 is a failure.
1.6 Serial setup (matters for a pty)
- 230400 8N1 (
src/rockblock_9704.h:119). A pty ignores the speed. setContextLinuxopens and immediately closes the port as a test (serial_linux.c:27-33), thenrbBeginopens it again (src/rockblock_9704.c:277-281). On a pty the master will seeEIOon read while no slave fd is open; the fake must tolerateEIOand keep the master open.- Opened
O_RDWR | O_NOCTTY | O_SYNC | O_NONBLOCK(serial_linux.c:41). Termios: CS8, no parity, 1 stop,ICRNL/IXON/IXOFF/IXANYoff,ICANON/ECHO/ECHOE/ISIGoff (serial_linux.c:90-98).c_oflagis untouched (the library never writes LF, soONLCRdoes not matter). - Immediately after opening,
clearLeftoverData()drains whatever is already waiting (src/rockblock_9704.c:135-151,283). Lines sent beforerbBeginare lost.
2. Every request, and the reply the library expects
All replies are 200 <same target> {...} unless noted. Fields not listed are
ignored. "valueint" means a JSON number read through cJSON's int.
2.1 GET apiVersion
>> GET apiVersion {}\r
<< 200 apiVersion {"supported_versions":[{"major":1,"minor":0,"patch":0}],"active_version":{"major":1,"minor":0,"patch":0}}\r
Source: src/jspr_command.c:21-33, parse src/jspr.c:281-338.
supported_versions: array of objects with numericmajor,minor,patch. At most 2 are kept, taken from the end of the array backwards:supportedVersions[0]is the last element (src/jspr.c:294-312).active_version: object with numericmajor,minor,patch. Present and complete meansactiveVersionSet = true; absent (not an object) means false (src/jspr.c:314-331).- The version values are not checked against anything.
2.2 PUT apiVersion
Sent only if the GET reply had no active_version (src/rockblock_9704.c:171-175).
>> PUT apiVersion {"active_version": {"major": 1, "minor": 0, "patch": 0}}\r
<< 200 apiVersion {"active_version":{"major":1,"minor":0,"patch":0}}\r
Source: src/jspr_command.c:35-56. The version sent is the last element of
the GET's supported_versions. Only the code 200 and target are checked;
the JSON is not parsed.
2.3 GET simConfig (the "simInterface" getter)
>> GET simConfig {}\r
<< 200 simConfig {"interface":"internal"}\r
Source: src/jspr_command.c:86-98, parse src/jspr.c:430-473.
interface string, one of none, local, remote, internal.
2.4 PUT simConfig
Sent if the GET did not say internal (src/rockblock_9704.c:200-215).
The library always asks for internal (src/rockblock_9704.c:202,
src/jspr_command.c:58-84).
>> PUT simConfig {"interface": "internal"}\r
<< 200 simConfig {"interface":"internal"}\r
<< 299 simStatus {"card_present":true,"sim_connected":true,"iccid":"8988169712345678901"}\r
The library needs the 200 simConfig and then, within 1 s, a
299 simStatus line (src/rockblock_9704.c:203-213). Order matters: a
299 simStatus sent before the 200 simConfig is discarded by the target
filter. The JSON of the 299 simStatus is not parsed here.
2.5 GET operationalState
>> GET operationalState {}\r
<< 200 operationalState {"state":"active","reason":0}\r
Source: src/jspr_command.c:163-176, parse src/jspr.c:475-541.
statestring:inactive,active,cal_test,hw_self_test,rf_scan,loopback,fault.reasonnumber 0..7 (normal, hw self test failure, temperature fault, RF power protection fault, VAM app failed ack, invalid hardware, low supply voltage, mfrtest used incorrectly;src/jspr.h:141-151). Optional.
2.6 PUT operationalState
>> PUT operationalState {"state": "active"}\r
<< 200 operationalState {"state":"active"}\r
Source: src/jspr_command.c:123-200. States written: inactive, active
(others exist in putOperationalState but the library only uses these two).
In rbBegin only the 200 is needed. In rbResyncServiceConfig the library
instead waits for a 299 operationalState carrying the new state (2.12).
2.7 GET simStatus (card present, SIM connected, ICCID)
>> GET simStatus {}\r
<< 200 simStatus {"card_present":true,"sim_connected":true,"iccid":"8988169712345678901"}\r
Source: src/jspr_command.c:367-379, parse src/jspr.c:1048-1078, callers
src/rockblock_9704.c:983-1026 (rbGetCardPresent, rbGetSimConnected,
rbGetIccid; each sends its own GET).
card_presentbool,sim_connectedbool (JSONtrue/false; numbers are ignored).iccidstring, truncated to 19 characters (JSPR_ICCID_MAX_LENGTH 20including the NUL,src/jspr.h:35,src/jspr.c:1071).
2.8 GET hwInfo (IMEI, serial number, hardware version, board temperature)
>> GET hwInfo {}\r
<< 200 hwInfo {"hw_version":"1.0","serial_number":"123456","imei":"300434030000000","board_temp":27}\r
Source: src/jspr_command.c:283-295, parse src/jspr.c:1009-1046, callers
src/rockblock_9704.c:927-981 (rbGetImei, rbGetHwVersion,
rbGetSerialNumber, rbGetBoardTemp; each sends its own GET).
| Field | Type | Kept |
|---|---|---|
hw_version |
string | first 6 chars (src/jspr.h:31) |
serial_number |
string | first 6 chars (src/jspr.h:32) |
imei |
string | first 15 chars (src/jspr.h:33) |
board_temp |
number | cast to int8 (-128..127) |
rbGetBoardTemp returns -100 on failure (src/rockblock_9704.c:974).
2.9 GET firmware (firmware version)
>> GET firmware {"slot": "primary"}\r
<< 200 firmware {"slot":"primary","validity":1,"version":{"major":1,"minor":2,"patch":3,"build_info":"fake"},"hash":"0000000000000000000000000000000000000000000000000000000000000000"}\r
Source: src/jspr_command.c:317-340, parse src/jspr.c:340-428, caller
src/rockblock_9704.c:1028-1062. rbGetFirmwareVersion returns
"v<major>.<minor>.<patch>" (src/rockblock_9704.c:1051).
slotmust be a string if present: the parser callsstrcmponvaluestringwithout a type check (src/jspr.c:366-377), so a non-string crashes the host. The same holds forversion.build_infoandhash(stpncpyonvaluestring,src/jspr.c:409,417).validityis read asvalueint > 0(src/jspr.c:382). cJSON does not setvalueintfortrue, so JSONtruereads as false; send1if it matters. Nothing in the public API readsvalidity.build_infokept to 49 chars,hashto 64.- On a non-200 the library prints
Failed\nto stdout (src/rockblock_9704.c:1042).
2.10 GET constellationState (signal)
>> GET constellationState {}\r
<< 200 constellationState {"constellation_visible":true,"signal_bars":4,"signal_level":-105}\r
Source: src/jspr_command.c:254-266, parse src/jspr.c:753-788, caller
src/rockblock_9704.c:908-925 (rbGetSignal).
- Waits up to 1 s for code 200 with this target (
src/rockblock_9704.c:912);299 constellationStatelines arriving meanwhile are consumed and ignored. constellation_visiblebool;signal_levelnumber, read only when visible is true;signal_barsnumber 0..5 (out of range ignored).- Returns
signal_bars, or -1 on failure.
2.11 GET messageProvisioning
Sent by checkProvisioning() the first time any send is attempted, then
never again in the life of the process (cached in a global,
src/rockblock_9704.c:40,1150-1206). rbBegin does not send it and does not
clear the cache.
>> GET messageProvisioning {}\r
<< 200 messageProvisioning {"provisioning":[{"topic_id":244,"topic_name":"RAW","priority":"Low","discard_time_seconds":604800,"max_queue_depth":8}]}\r
Source: src/jspr_command.c:269-281, parse src/jspr.c:934-1007.
provisioning: array, at most 20 entries used (JSPR_MAX_TOPICS,src/jspr.h:29).topicCountis set to the array size even above 20.- Per entry:
topic_idnumber 64..65535 (below 64 is ignored and the entry's id is left as stack garbage);topic_namestring shorter than 57 chars;prioritystring, one ofSafety-1,Safety-2,Safety-3,High,Medium,Low. - The struct has
discardTimeSecondsandmaxQueueDepth(src/jspr.h:259-266) but the parser never reads them; the field names above are placeholders, not known from the source. provisioningSetbecomes true whenever the JSON parses at all (src/jspr.c:1001), with or without the array. The library caches the result even when the requested topic is missing, so a fake cannot change provisioning later without restarting the host process.- The
messageProvisioningcallback (Python:set_message_provisioning_callback) fires only here, after the GET (src/rockblock_9704.c:1181-1187).
2.12 PUT serviceConfig (resync), with its state dance
rbResyncServiceConfig() (src/rockblock_9704.c:1064-1120):
>> GET operationalState {}\r
<< 200 operationalState {"state":"active","reason":0}\r (within 1 s)
>> PUT operationalState {"state": "inactive"}\r
<< 200 operationalState {"state":"inactive"}\r (consumed and ignored)
<< 299 operationalState {"state":"inactive","reason":0}\r (required, within 1 s)
>> PUT serviceConfig {"resync": true}\r
<< 200 serviceConfig {}\r (required, within 1 s)
>> PUT operationalState {"state": "active"}\r
<< 200 operationalState {"state":"active"}\r (consumed and ignored)
<< 299 operationalState {"state":"active","reason":0}\r (required, within 1 s)
If the modem was already inactive, the first PUT and the last two exchanges
are skipped and the call succeeds on 200 serviceConfig. Any other state
fails without sending anything more. Request text: src/jspr_command.c:381-400.
2.13 PUT messageOriginate, PUT messageOriginateSegment, PUT messageOriginateStatus
See section 4. Request text: src/jspr_command.c:201-252,402-421.
2.14 GET/PUT firmware for update (C only, KERMIT builds)
rbUpdateFirmware (src/rockblock_9704.c:1208-1372) does the
GET operationalState / PUT operationalState inactive / wait for
299 operationalState sequence of 2.12, then
>> PUT firmware {"slot": "primary"}\r, needs 200 firmware {...} and
then switches the line to Kermit. Not in the Python binding; out of scope
for the fake.
2.15 What the library never sends
There is no request for messageTerminate, messageTerminateSegment or
messageTerminateStatus, no MT acknowledgement of any kind, and no
GET bootInfo. parseJsprBootInfo exists (src/jspr.c:198-279) and is
never called.
3. rbBegin
rbBegin(port) (src/rockblock_9704.c:273-300); the Arduino one is the same
(src/rockblock_9704_arduino.cpp:9-26):
- Open, close, open the port (1.6). Drain input.
setApi()(src/rockblock_9704.c:153-186), up to 2 attempts, each after a 5 ms sleep:Succeeds if the reply is 200 and>> GET apiVersion {}\r << 200 apiVersion {"supported_versions":[{"major":1,"minor":0,"patch":0}],"active_version":{"major":1,"minor":0,"patch":0}}\ractive_versionis present. Ifactive_versionis absent it sendsPUT apiVersion(2.2) and succeeds on a200 apiVersion.setSim()(src/rockblock_9704.c:188-224):Succeeds at once if>> GET simConfig {}\r << 200 simConfig {"interface":"internal"}\rinternal. Otherwise PUT,200 simConfig, then299 simStatuswithin 1 s (2.4). No retry.setState()(src/rockblock_9704.c:226-271):>> GET operationalState {}\r << 200 operationalState {"state":"active","reason":0}\ractive: success.inactive:>> PUT operationalState {"state": "active"}\r, needs200 operationalState. A299 operationalStatesent before that 200 is taken as the reply (same target) and fails the begin, because its code is not 200. Send the 200 first.- any other state:
PUT ... "inactive", needs200 operationalState, thenPUT ... "active"and reads the reply with the misspelt target"operationState"(src/rockblock_9704.c:259). No real line matches, so this path always fails (see 8). statemissing or not a string: fails.
imtQueueInit()empties both queues (src/imt_queue.c:170-201).
Minimal fake for a clean begin: answer the three GETs with active_version
set, "interface":"internal", and "state":"active". That is three
request/response pairs and nothing unsolicited.
4. Mobile originated (MO)
4.1 checkProvisioning (all send functions start with it)
src/rockblock_9704.c:1150-1206.
- Topic must be 64..65535 (
IMT_MIN_TOPIC_ID/IMT_MAX_TOPIC_ID,src/rockblock_9704.c:20-21). Below 64 fails with nothing on the wire. - First call:
GET messageProvisioning(2.11). Needs200and the target. The topic must appear as atopic_idin theprovisioningarray. - Later calls: checked against the cache only; nothing on the wire.
- Failure means the send returns false and nothing else is sent.
rbSendMessage (Python send_message(msg) with no topic) uses topic 244
(RAW_TOPIC, src/rockblock_9704.h:160, src/rockblock_9704.c:347).
Other named Cloudloop topics: 313..317 (src/rockblock_9704.h:163-175).
4.2 Payload and CRC
- Payload length 1..100000 bytes on Linux (
IMT_PAYLOAD_SIZE 100000U + IMT_CRC_SIZE,src/imt_queue.h:36-38; check atsrc/rockblock_9704.c:353,375,397,595). 5000 on Arduino (src/imt_queue.h:31-34). Both overridable at build time (CMakeLists.txt:14-20). - A 2-byte CRC is appended to the payload in the library's buffer
(
appendCrc,src/rockblock_9704.c:331-341): CRC-16 with polynomial 0x1021, initial value 0, no reflection, no final XOR (table atsrc/rockblock_9704.c:99-133, loop atsrc/rockblock_9704.c:1122-1137). This is CRC-16/XMODEM. Appended high byte first.- Check value:
"123456789"gives0x31C3. "hello"gives0xC362, so the 7 bytes sent are68 65 6c 6c 6f c3 62, base64aGVsbG/DYg==.
- Check value:
message_lengthon the wire is payload + 2.
4.3 The exchange
Example: rbSendMessageAsync(244, "hello", 5), first send of the process.
>> GET messageProvisioning {}\r
<< 200 messageProvisioning {"provisioning":[{"topic_id":244,"topic_name":"RAW","priority":"Low"}]}\r
>> PUT messageOriginate {"topic_id":244, "message_length":7, "request_reference":1}\r
<< 200 messageOriginate {"topic_id":244,"request_reference":1,"message_id":17,"message_response":"message_accepted"}\r
... library returns; from here on it only reacts inside rbPoll() ...
<< 299 messageOriginateSegment {"topic_id":244,"message_id":17,"segment_length":7,"segment_start":0}\r
>> PUT messageOriginateSegment {"topic_id":244, "message_id":17, "segment_length":7, "segment_start":0, "data":"aGVsbG/DYg=="}\r
<< 200 messageOriginateSegment {"topic_id":244,"message_id":17,"segment_length":7,"segment_start":0}\r (ignored by the library)
<< 299 messageOriginateStatus {"topic_id":244,"message_id":17,"final_mo_status":"mo_ack_received"}\r
Step by step:
- PUT messageOriginate (
src/jspr_command.c:201-228).request_referenceis a global counter starting at 1, incremented after every PUT messageOriginate and wrapped from 101 to 1 (src/jspr.c:16,src/jspr_command.c:212-216). - Reply read with
receiveJspr(..., "messageOriginate")(src/rockblock_9704.c:429,562), so it must come within 0.5 s and before any299line. Parse:src/jspr.c:543-602.topic_id64..65535,request_reference1..100 (both stored, neither checked against the request).message_responsestring:message_accepted,subscription_invalid,message_discarded_on_overflow.message_id0..255, read only whenmessage_responseismessage_accepted.- Code 200 alone counts as started (
src/rockblock_9704.c:431-436, 564-569);message_responseis never tested. Always sendmessage_acceptedwith amessage_idon 200, or the library keeps a garbage id. To refuse a message send a non-200 code, e.g.406 messageOriginate {}, and the library drops it. - The
moMessageStarted(id)callback fires here in the async path (src/rockblock_9704.c:571-574).
- Segment request, unsolicited, from the modem:
299 messageOriginateSegmentwithtopic_id,message_id,segment_length(1..1446),segment_start(0..100001) (src/jspr.c:604-650). The library answers only if bothmessage_idandtopic_idmatch the head of its MO queue (src/rockblock_9704.c:721-727). Out-of-range values leave the field as stack garbage. - Segment reply from the library (
src/rockblock_9704.c:728-739,src/jspr_command.c:230-252): the bytesbuffer[segment_start .. segment_start+segment_length)of payload+CRC, standard base64 with padding and no line breaks (vendored mbedtls,src/third_party/base64/base64.c), into a 2048-byte buffer (BASE64_TEMP_BUFFER,src/rockblock_9704.h:112). 1446 bytes encode to 1928 characters, which fits. The library does not wait for a reply to this PUT; it is fire and forget.- The segment size is chosen by the modem. The library imposes the
1..1446 range only. Nothing in the library computes segments; the fake
must request every range covering
0 .. message_lengthitself, in any order, one299per segment. Each request is served by onerbPoll. - The library does not check that the range lies within the message.
Asking past
message_lengthreturns stale buffer bytes (zeros after init).
- The segment size is chosen by the modem. The library imposes the
1..1446 range only. Nothing in the library computes segments; the fake
must request every range covering
- Reply to the segment PUT: a
200 messageOriginateSegmentis read by a laterrbPolland ignored. Any code other than 200 and 299 with targetmessageOriginateSegmentand a matchingmessage_idfails the MO (src/rockblock_9704.c:742-761):moMessageComplete(id, -1)or the sync failure flag, and the message is dropped. To exercise this:<< 405 messageOriginateSegment {"topic_id":244,"message_id":17}\r(it must carrymessage_id; the topic is not checked). - Final status, unsolicited:
299 messageOriginateStatuswithmessage_idmatching the head (topic not checked) andfinal_mo_status(src/jspr.c:790-880,src/rockblock_9704.c:762-795).mo_ack_receivedis the only success.- Failures:
message_discarded_on_overflow,message_expired,message_transfer_timeout,segment_not_supplied,segment_incorrect,network_error,message_cancelled_pre_transit,message_cancelled_in_transit,subscription_invalid,protocol_error,message_dropped_local_crc_error,crc_error_in_transfer,user_supplied_crc_error. - Either way the message leaves the queue, and the next queued async MO
(if any) is started at once with its own
PUT messageOriginate(checkMoQueue,src/rockblock_9704.c:691-702).
The library never checks a CRC on the MO side itself; a fake that wants to
emulate user_supplied_crc_error should reassemble the segments, check the
last two bytes as CRC-16/XMODEM big-endian over the rest, and choose the
final status.
4.4 rbSendMessageAsync (Python send_message_async(msg, topic))
src/rockblock_9704.c:589-617, 548-587.
- Returns after the
200 messageOriginate. Segments and status are handled only when the caller runsrbPoll()(Pythonpoll()), one line per call. The upstream example polls every 10 ms (examples/python/async_send_receive.py). - Outcome arrives through
moMessageComplete(id, 1 | -1). - The MO queue holds 1 message (
IMT_QUEUE_SIZE 1,src/imt_queue.h:17-19) and starts locked (moLock = true,src/imt_queue.c:10), so a second async send while one is in flight fails at once with nothing on the wire. - The Python wrapper returns
Noneand sends nothing iftopicis omitted (rockblock9704/rockblock9704.py:114-122).
4.5 rbSendMessage / rbSendMessageAny (Python send_message(msg[, topic][, timeout=30]))
src/rockblock_9704.c:343-363, 387-407, 409-470.
- Same wire exchange. After the
200 messageOriginatethe library spins onrbPoll()with no sleep until success, failure ortimeoutseconds measured from before the PUT (src/rockblock_9704.c:413,437-457). - The fake must deliver segment requests and the final status within that timeout, unprompted, because the library sends nothing in the meantime except segment PUTs.
- Success is seen only through a flag that is set only when no
moMessageCompletecallback is registered (src/rockblock_9704.c:771-778). See 8.
5. Mobile terminated (MT)
5.1 The exchange
Everything is unsolicited from the modem. The library sends nothing at
all for an MT: no request, no segment acknowledgement, no final ack.
rbAcknowledgeReceiveHeadAsync only removes the message from the
library's own queue (src/rockblock_9704.c:681-689, src/imt_queue.c:149-168).
Example: delivering "hello" on topic 244 as message id 5.
<< 299 messageTerminate {"topic_id":244,"message_id":5,"message_length_max":7}\r
<< 299 messageTerminateSegment {"topic_id":244,"message_id":5,"segment_length":7,"segment_start":0,"data":"aGVsbG/DYg=="}\r
<< 299 messageTerminateStatus {"topic_id":244,"message_id":5,"final_mt_status":"complete"}\r
299 messageTerminate(src/jspr.c:652-690,src/rockblock_9704.c:798-818):topic_id64..65535,message_id0..255,message_length_max3..100002.- The library adds an entry to its MT queue (one slot). If the queue is
full and unlocked (the default,
mtLock = false,src/imt_queue.c:9) the old entry is dropped to make room (src/imt_queue.c:39-60). If locked (rbReceiveLockAsync) the new MT is refused andmtMessageComplete(id, -1)fires. message_length_maxis used only as "length > 0" at this point; the final length comes from the segments.
299 messageTerminateSegment(src/jspr.c:692-751,src/rockblock_9704.c:819-850), zero or more:- Applied to the last MT queued, and only if its
message_idmatches. Topic not checked. segment_start0..100001,segment_length1..1446.data: standard base64 of the segment bytes. Decoded straight into the message buffer atsegment_start, withsegment_lengthas the output limit. If the decoded size exceedssegment_length, or the base64 has an invalid character, the MT fails (mtMessageComplete(id, -1)) and is removed.- The
datastring is truncated to 1446 characters before decoding (JSPR_MAX_SEGMENT_LENGTH 1447,src/jspr.h:21,src/jspr.c:736-744). So in practice keep each MT segment to at most 1083 bytes (1444 base64 characters), even though the parser acceptssegment_lengthup to 1446. A 1446-byte segment (1928 characters) would be silently cut and decoded short. This is the library's limit; whether a real modem sends larger MT segments is not answerable from the source. - Each segment adds its
segment_lengthto a running total (messageLengthAsync,src/rockblock_9704.c:834). Do not repeat or overlap segments, the total would be wrong.
- Applied to the last MT queued, and only if its
299 messageTerminateStatus(src/jspr.c:882-932,src/rockblock_9704.c:851-892):message_idmust match the last MT.final_mt_status:complete(success),message_timed_out,message_cancelled,crc_error_in_transfer.- On
complete: message length = sum ofsegment_lengths, counter reset,ready = true, thenmtMessageComplete(id, 1)or the sync flag. - On anything else:
mtMessageComplete(id, -1)or the sync failure flag; the entry stays in the queue and the running total is not reset.
5.2 CRC on MT
The library treats the last 2 bytes of the reassembled MT as a CRC and
strips them without checking them (src/rockblock_9704.c:484-485, 506-507, 650-651). So the fake must send payload + 2 bytes. For realism
use the same CRC-16/XMODEM, high byte first, as MO ("hello" gives
aGVsbG/DYg== as above). The library writes a NUL over the first CRC byte
and returns length - 2 bytes. Total length below 2 underflows; keep MTs to
at least 1 byte of payload (the parser's minimum message_length_max is 3).
5.3 rbReceiveMessageAsync and rbAcknowledgeReceiveHeadAsync
rbReceiveMessageAsync(&buf)(src/rockblock_9704.c:636-659, Pythonreceive_message_async()with no topic): does not touch the serial line. Returns the head MT ifready, otherwise 0. It can be called repeatedly and returns the same message until it is acknowledged.rbAcknowledgeReceiveHeadAsync()(Pythonacknowledge_receive_head_async()): local only, zeroes and removes the head. Nothing is sent to the modem.- The lines themselves are processed by
rbPoll(), one per call.
5.4 rbReceiveMessage (sync, Python receive_message())
src/rockblock_9704.c:472-546. Calls rbPoll() once. If the head MT is
waiting for segments (readyToProcess), it then spins on rbPoll() with
no timeout until the MT succeeds or fails. Otherwise it returns 0 at once.
So the usual pattern is the caller looping on receive_message(); the call
that reads the 299 messageTerminate then blocks until the status line.
rbReceiveMessageWithTopic(&buf, topic) (src/rockblock_9704.c:494-515)
does not filter by topic; the parameter is assigned to and discarded.
6. Unsolicited constellationState and messageProvisioning
6.1 299 constellationState
<< 299 constellationState {"constellation_visible":true,"signal_bars":3,"signal_level":-110}\r
Handled in rbPoll (src/rockblock_9704.c:893-903) with the same parser as
the GET (src/jspr.c:753-788): constellation_visible bool,
signal_level number (read only when visible), signal_bars 0..5. Calls the
constellationState callback (Python dict constellationVisible,
signalBars, signalLevel, rockblock9704/rockblock.c:106-132). Nothing is
stored; rbGetSignal always sends a fresh GET. The struct is uninitialised
(src/rockblock_9704.c:895), so with constellation_visible:false the
signal_level handed to the callback is garbage, and a missing
signal_bars is garbage too. Always send signal_bars.
6.2 299 messageProvisioning
Not handled. No code path looks at an unsolicited messageProvisioning;
rbPoll consumes and ignores it. Provisioning is learned only through the
one GET messageProvisioning in 2.11.
6.3 Other unsolicited lines
299 simStatus matters only inside setSim (2.4). 299 operationalState
matters only inside rbResyncServiceConfig and rbUpdateFirmware (2.12).
Everything else is ignored.
7. Constants a fake must respect
| Constant | Value | Where | Meaning for the fake |
|---|---|---|---|
| line terminator | \r |
src/jspr.c:65, src/jspr_command.c |
both directions |
| first-byte / inter-byte timeout | 0.5 s | serial_linux.c:153 |
reply fast, write lines whole |
RX_BUFFER_SIZE |
8192 | src/jspr.h:13 |
max line to the library |
JSPR_MIN_RESPONSE |
9 | src/jspr.h:18 |
min line length |
JSPR_MAX_TARGET_LENGTH |
30 | src/jspr.h:16 |
target < 30 chars |
JSPR_MAX_JSON_LENGTH |
3500 | src/jspr.h:20 |
JSON < 3500 chars |
COMMAND_MAX_LEN |
2048 | src/jspr_command.h:12 |
longest request line |
BASE64_TEMP_BUFFER |
2048 | src/rockblock_9704.h:112 |
MO segment base64 |
MO segment_length |
1..1446 | src/jspr.c:624 |
request no more than 1446 |
MT segment_length |
1..1446 parsed; data cut at 1446 chars | src/jspr.c:712,738-741 |
use <= 1083 bytes |
segment_start |
0..100001 | src/jspr.c:632,720 |
|
message_length_max |
3..100002 | src/jspr.c:672 |
|
message_id |
0..255 | src/jspr.c:590,640,680,728,810,902 |
|
request_reference |
1..100, wraps | src/jspr_command.c:212-216 |
|
| topic id | 64..65535 | src/rockblock_9704.c:20-21 |
|
IMT_PAYLOAD_SIZE |
100000 + 2 (Linux), 5000 + 2 (Arduino) | src/imt_queue.h:31-39 |
max payload + CRC |
IMT_CRC_SIZE |
2 | src/imt_queue.h:25 |
CRC-16/XMODEM, big-endian |
IMT_QUEUE_SIZE |
1 | src/imt_queue.h:17-19 |
one MO and one MT in flight |
JSPR_MAX_TOPICS |
20 | src/jspr.h:29 |
provisioning entries used |
JSPR_TOPIC_NAME_MAX_LENGTH |
57 | src/jspr.h:28 |
name < 57 chars |
| baud | 230400 | src/rockblock_9704.h:119 |
8. Surprises and quirks a fake should know
- Lines that arrive while the library waits for a reply are discarded
if their target differs (1.3). Unsolicited MT or segment requests sent
during a GET/PUT exchange are lost for good. Order every exchange as:
solicited
200first, then299s. - A line with no 3-digit code in 200..500 makes the code scan run off
the end of the line through the zeroed buffer, then
memmovewith a negative length cast tosize_t(src/jspr.c:89-113). Expect a crash. Never send banners, prompts or blank-ish lines of 9 bytes or more. resultCodeIndexStartis not reset after a line is discarded for the wrong target (src/jspr.c:48,133-136): if that line had leading junk, the next line is read from the same offset. Do not send leading junk.- A pause of 0.5 s inside a line after a discarded line makes the
partial line be parsed as a response, because
validResponsestays true (src/jspr.c:80after thecontinueat 136). - setState misspells its target as
"operationState"(src/rockblock_9704.c:259), sorbBeginfrom any state other thanactiveorinactivealways fails. 299 operationalStatebefore200 operationalStatefailsrbBeginfrominactive(3, step 4).- Sync send with a callback registered never reports success.
moSentandmtReceivedare set only when the matching callback is absent (src/rockblock_9704.c:771-778,868-875). In Python, onceset_mo_message_complete_callbackhas been called,send_message()always runs to its timeout and returns False, even though the message went. Same forreceive_message()with an MT callback: it never returns. - After a sync send times out the MO stays in the queue (started, never
removed;
src/rockblock_9704.c:452-456,464-467), and the queue is locked by default. The nextrbSendMessagecannot add (returns false with only the provisioning check, if any, on the wire); the nextrbSendMessageAnyignores the failed add (queued >= 0,src/rockblock_9704.c:400) and re-sends the old stuck message with a newPUT messageOriginate. A late299 messageOriginateStatusfor the old id, read by any laterrbPoll, clears it. 200 messageOriginatealone means started, whatevermessage_responsesays (4.3 step 2).rbReceiveMessagehas no timeout once an MT has been announced. If the fake announces an MT and never sends a status, the host blocks forever (with the GIL held, see 11).- The Python binding never releases the GIL (no
Py_BEGIN_ALLOW_THREADSinrockblock9704/rockblock.c). Each blocking C call (begin,send_messagefor up totimeouts,receive_message, every 0.5 s read timeout) freezes every Python thread in that process. Run the fake in a separate process, not a thread beside the code under test. - MT running length leaks between messages:
messageLengthAsyncis reset only on acompletestatus (src/rockblock_9704.c:865-866). After a failed or abandoned MT, the next MT's reported length includes the old segments' lengths. - MT CRC is stripped, never checked (5.2). A fake that sends no CRC loses the last 2 bytes of every message.
- MT data longer than 1446 base64 characters is truncated silently (5.1).
- Provisioning is fetched once per process and cached even on a miss
(2.11). Unsolicited
messageProvisioningis ignored (6.2). - Uninitialised structs everywhere. Every parser fills a stack struct
the caller did not zero (
src/rockblock_9704.c:169,197,235,433,566,723, 800,826,858,895,915,1069,1178). A missing or out-of-range field leaves garbage that may still be acted on (for example a garbagemessage_idthat happens to match). Always send every field listed above, in range. - Non-string where a string is expected crashes in the firmware parser (2.9).
rbPollreads one line per call and only whenFIONREAD > 0. A burst of N lines needs N polls. Multi-segment MOs and MTs need the host to poll repeatedly; the sync paths do that themselves in a busy loop.- The 9-byte minimum and
pos > 2rule mean short lines merge into the next one (1.2). Any real JSPR line is longer than 9 bytes, so this only bites junk. clearLeftoverDatareads up to 32 bytes into a 32-byte buffer and the Linux read writes a NUL after them (src/rockblock_9704.c:137-149,serial_linux.c:178), one byte past the end. Avoid sending anything beforerbBegin.- Cancellation (
rbCancelMessage, Pythoncancel_message(topic, id)):>> PUT messageOriginateStatus {"topic_id": 244, "message_id": 17, "action": "cancel"}\r(src/rockblock_9704.c:619-634). The reply is not read; the200is swallowed by a laterrbPoll. The outcome must come as299 messageOriginateStatuswith, for example,"final_mo_status":"message_cancelled_pre_transit".
9. A minimal session, end to end
(host opens port, drains)
>> GET apiVersion {}\r
<< 200 apiVersion {"supported_versions":[{"major":1,"minor":0,"patch":0}],"active_version":{"major":1,"minor":0,"patch":0}}\r
>> GET simConfig {}\r
<< 200 simConfig {"interface":"internal"}\r
>> GET operationalState {}\r
<< 200 operationalState {"state":"active","reason":0}\r
(rbBegin returns true)
>> GET hwInfo {}\r
<< 200 hwInfo {"hw_version":"1.0","serial_number":"123456","imei":"300434030000000","board_temp":27}\r
>> GET constellationState {}\r
<< 200 constellationState {"constellation_visible":true,"signal_bars":5,"signal_level":-100}\r
(send_message(b"hello"), topic 244, sync)
>> GET messageProvisioning {}\r
<< 200 messageProvisioning {"provisioning":[{"topic_id":244,"topic_name":"RAW","priority":"Low"}]}\r
>> PUT messageOriginate {"topic_id":244, "message_length":7, "request_reference":1}\r
<< 200 messageOriginate {"topic_id":244,"request_reference":1,"message_id":1,"message_response":"message_accepted"}\r
<< 299 messageOriginateSegment {"topic_id":244,"message_id":1,"segment_length":7,"segment_start":0}\r
>> PUT messageOriginateSegment {"topic_id":244, "message_id":1, "segment_length":7, "segment_start":0, "data":"aGVsbG/DYg=="}\r
<< 200 messageOriginateSegment {"topic_id":244,"message_id":1}\r
<< 299 messageOriginateStatus {"topic_id":244,"message_id":1,"final_mo_status":"mo_ack_received"}\r
(send_message returns True, if no MO callback is registered)
(receive_message() in a loop)
<< 299 messageTerminate {"topic_id":244,"message_id":2,"message_length_max":7}\r
<< 299 messageTerminateSegment {"topic_id":244,"message_id":2,"segment_length":7,"segment_start":0,"data":"aGVsbG/DYg=="}\r
<< 299 messageTerminateStatus {"topic_id":244,"message_id":2,"final_mt_status":"complete"}\r
(receive_message returns b"hello")
The JSON field order and spacing in the << lines are free; the library
looks fields up by name. Field names the library reads are exactly those
listed in section 2; any extra fields a real modem sends are ignored.