Conversation
…ction The Shimmer3/Shimmer3R protocol has had a NACK_COMMAND_PROCESSED (0xFE) reply for a long time, but the driver never learned about it. There was no NACK constant anywhere on the Shimmer3 side, and both protocol implementations tested only for ACK with no else branch, so a NACK was read, silently discarded, and the transaction stalled with mWaitForAck still true and the instruction stack still locked. Two seconds later the ACK timer expired and checkForAckOrRespTask called connectionLost(): a refused command was indistinguishable from a dead link, and there was no retry to soften it because NUMBER_OF_TX_RETRIES_LIMIT is 0 and reading the NACK byte itself resets mNumberofTXRetriesCount, making the soft-recovery branch dead code. This is already reachable on shipping firmware - any SET command while the device is sensing, a sync-mode mismatch, a failed SET_DAUGHTER_CARD_MEM, and the BMP180/BMP280 calibration reads on a Shimmer3R all NACK. The existing coping strategy was avoidance rather than handling; see the comment on GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND, which is skipped for a BMP581 specifically because the firmware NACKs it. A refusal now unwinds the transaction and keeps the connection: - ShimmerObject: add NACK_COMMAND_PROCESSED = (byte) 0xFE. - ShimmerBluetooth.processNackFromCommand(): stop the ACK timer, clear mWaitForAck/mWaitForResponse, drop the refused instruction so the stack advances (as both ACK paths do for their own command), complete the transaction and release the stack lock. - Handled in three places: waiting for an ACK while not streaming (the main path), waiting for a response while not streaming, and a data packet followed by a NACK while streaming, which is the SET-while-sensing case. - eventCommandRefused() is the application hook. It is deliberately concrete with an empty default rather than abstract, and not a new ProtocolListener callback, so every existing subclass and listener implementation keeps compiling. Override it to surface the refusal. Two supporting changes: - The resync helper accepted only 0x00 and 0xFF as a packet boundary, so a NACK in the buffer could not be skipped past cleanly. It now accepts 0xFE too, and is renamed from findOffsetOfNextZeroOrFF to findOffsetOfNextPacketBoundary to match what it does. - NACK is registered in mBtCommandMapOther so btCommandToString names it in the logs. Deliberately not in mBtResponseMap: isKnownResponse() must stay false for it, or processResponseCommand() would try to parse it as a response body. A stray NACK arriving with no command in flight is logged and ignored rather than turned into a refusal, in processBytesAvailableAndInstreamSupported() which runs only when !mWaitForAck && !mWaitForResponse. The TypeScript SDK gates its own NACK framing the same way, so a leaked stream byte cannot fabricate a refusal. LiteProtocol gets the same treatment. Its NACK value is declared locally because the generated LiteProtocolInstructionSet has no NACK entry; adding it to LiteProtocolInstructionSet.proto and regenerating would be tidier but rewrites a large generated file committed in two places for one constant. Compile-verified only - there is no test harness for the BT receive path (ShimmerBluetooth is abstract with many abstract I/O methods and no test instantiates it), and no hardware was involved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
There is at least one confirmed transaction-stack correctness bug (and an uncovered NACK path in LiteProtocol) that can cause queued commands to be dropped or time out incorrectly.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Updates the Shimmer driver to recognize and handle NACK_COMMAND_PROCESSED (0xFE) so a refused command unwinds the in-flight transaction and the connection is not dropped due to an ACK/response timeout.
Changes:
- Adds a protocol constant for NACK (
0xFE) and registers it for logging/command-to-string mapping. - Handles NACK in
ShimmerBluetoothacross ACK-wait, response-wait, and streaming (data packet followed by NACK) paths. - Adds analogous NACK handling and an overridable
eventCommandRefused(...)hook inLiteProtocol.
File summaries
| File | Description |
|---|---|
| ShimmerDriver/src/main/java/com/shimmerresearch/driver/ShimmerObject.java | Adds NACK_COMMAND_PROCESSED constant (0xFE). |
| ShimmerDriver/src/main/java/com/shimmerresearch/comms/radioProtocol/LiteProtocol.java | Introduces NACK constant and transaction-unwind handling plus eventCommandRefused(...). |
| ShimmerDriver/src/main/java/com/shimmerresearch/bluetooth/ShimmerBluetooth.java | Adds NACK support in multiple receive paths, updates packet-boundary resync, and adds eventCommandRefused(...). |
Review details
Suppressed comments (1)
ShimmerDriver/src/main/java/com/shimmerresearch/bluetooth/ShimmerBluetooth.java:1379
- findOffsetOfNextPacketBoundary() uses a raw
0instead of the existing DATA_PACKET constant; now that this method was touched, using the named constant would avoid a magic number and match the surrounding code style (e.g., bufferTemp[0]==DATA_PACKET).
byte b = buffer[i];
if (b == 0 || b == ACK_COMMAND_PROCESSED || b == NACK_COMMAND_PROCESSED)
return i;
- Files reviewed: 3/3 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| byte refusedCommand = mCurrentCommand; | ||
|
|
||
| mWaitForAck=false; | ||
| mWaitForResponse=false; | ||
|
|
||
| //Drop the refused instruction, as both ACK paths do for their command | ||
| if(getListofInstructions().size()>0){ | ||
| removeInstruction(0); | ||
| } | ||
| removeAllNulls(); |
There was a problem hiding this comment.
Confirmed and fixed in 648897b — this was a real bug, thanks. The GET path does removeInstruction(0) as soon as its ACK arrives, so a NACK in the response-wait state was dropping whatever was queued behind it and silently skipping that command.
Rather than pass a flag from each call site, processNackFromCommand() now captures mWaitForAck on entry: that is true only in the state that still has the refused instruction queued (the ACK block clears it before setting mWaitForResponse), and it is also true in the streaming data-packet-then-NACK path. The javadoc no longer claims the drop is unconditional. Same fix applied to LiteProtocol.processNackFromCommand().
| /* The Shimmer refused the command. Without this branch the NACK | ||
| * was discarded and the transaction stalled until the ACK timer | ||
| * expired, which is treated as a lost connection. */ | ||
| else if((((int)byteBuffer[0])&0xFF)==NACK_COMMAND_PROCESSED_VALUE) { | ||
| processNackFromCommand(); |
There was a problem hiding this comment.
Fixed in 648897b. Added the response-wait NACK branch to LiteProtocol, checked before isKnownResponseByte() so it mirrors ShimmerBluetooth.processNotStreamingWaitForResp() exactly. It shares the same processNackFromCommand(), so it also picks up the conditional-drop fix from the other comment.
…pilot review) processNackFromCommand() removed instruction 0 unconditionally, but a GET's instruction is already removed when its ACK arrives. A NACK arriving in the response-wait state therefore dropped whatever was queued behind it and silently skipped that command. It is now dropped only when mWaitForAck was still set, which is exactly the state that still has it queued. Same fix in LiteProtocol, and the javadoc no longer claims the drop is unconditional. Also from the review: - LiteProtocol was missing the response-wait NACK branch that ShimmerBluetooth has, so a NACK there would still have fallen through to the timeout. Added, checked before isKnownResponseByte() to match. - The resync helper's inline comment still said it looked for DATA_PACKET or ACK only. - The helper used a raw 0 rather than the DATA_PACKET constant it now sits alongside (suppressed review comment). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
All four review items are addressed in Suppressed comment ( Summary of the round:
The PR description has been updated rather than just corrected here, since the first version described the instruction drop as unconditional and a future reader could otherwise "fix" the code back to that. Still compile-verified only, and still no automated coverage of the receive path — the transaction-stack bug is a good argument for the fake-transport harness noted in the description, since a test would have caught it and review only just did. |
There was a problem hiding this comment.
🔵 Needs a closer look
It changes low-level transport state-machine behavior (including streaming parsing) without an automated/hardware-backed test to validate the end-to-end receive/unwind paths.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Lite
The protocol has had a
NACK_COMMAND_PROCESSED(0xFE) reply for a long time, butthe driver never learned about it. Grepping the whole driver for
NACK— excludingthe generated instruction set and the unrelated Verisense protocol — returned two
comments and nothing else.
What was happening
Both protocol implementations tested only for ACK, with no
else:ShimmerBluetooth.java:833LiteProtocol.java:569So a NACK was read, silently discarded, and the transaction stalled with
mWaitForAckstill true and the instruction stack still locked. Two seconds later(
ACK_TIMER_DURATION) the ACK timer expired andcheckForAckOrRespTaskcalledconnectionLost().There was no retry to soften it.
NUMBER_OF_TX_RETRIES_LIMITis0, and readingthe NACK byte itself resets
mNumberofTXRetriesCountto0, so0 >= 0alwaysholds, one of the two
connectionLost()branches always won, and the soft-recoveryelsewas dead code. While streaming the same timeout instead cleared the entirequeued instruction stack.
A refused command was indistinguishable from a dead link, and cost the
connection.
This is not hypothetical and does not depend on any pending firmware change. The
shipping firmware NACKs on:
ShimBt_isCmdBlockedWhileSensing,which covers most of the configuration surface)
SET_DAUGHTER_CARD_MEMwhen the EEPROM write failsGET_BMP180/GET_BMP280_CALIBRATION_COEFFICIENTSon a Shimmer3R, andGET_PRESSURE_CALIBRATION_COEFFICIENTSon a BMP581The existing coping strategy was avoidance rather than handling, which is decent
evidence the failure mode is real —
ShimmerBluetooth.java:3344skips the BMP581calibration read with the comment "its firmware NACKs … so don't send it."
Avoiding each NACK individually does not scale, and it makes every new firmware
NACK a latent connection-drop in every released copy of the driver.
What this changes
A refusal now unwinds the transaction and keeps the connection up.
ShimmerObjectNACK_COMMAND_PROCESSED = (byte) 0xFEprocessNackFromCommand()mWaitForAck/mWaitForResponse, drops the refused instruction so the stack advances, completes the transaction, releases the stack lockeventCommandRefused()The refused instruction is dropped only when it is still queued, which
processNackFromCommand()determines frommWaitForAckon entry. A GET'sinstruction is removed as soon as its ACK arrives, so an unconditional removal
would drop whatever was queued behind it and silently skip that command —
Copilot caught that in review, and it is fixed in
648897ba. TheACK-wait and streaming paths both still have the instruction queued; the
response-wait path does not.
Handled in three places:
firmware only discovers it cannot serve a GET while building the response.
Checked before
isKnownResponse().LiteProtocolgained the matching branchin
648897baafter review; it only had the ACK-wait one at first.SET-while-sensing case, the most reachable one of the lot.
Two deliberate non-obvious choices
eventCommandRefused()is concrete with an empty default, not abstract — andnot a new
ProtocolListenercallback either. Every other notification hook here(
sendProgressReport,connectionLost,eventLogAndStreamStatusChanged) isprotected abstract, so adding one would break every downstream subclass andlistener implementation at compile time. This way nothing outside the driver has to
change, and anyone who wants the refusal surfaced overrides it.
A stray NACK with no command in flight is logged and ignored, not treated as a
refusal.
processBytesAvailableAndInstreamSupported()runs only when!mWaitForAck && !mWaitForResponse, so there is no transaction to fail there. TheTypeScript SDK gates its own NACK framing the same way (on a command genuinely
awaiting a reply) so that a leaked stream byte cannot fabricate a refusal; same
reasoning applied here.
Two supporting changes
0x00and0xFFas a packet boundary, so aNACK sitting in the buffer could not be skipped past cleanly. It now accepts
0xFE, and is renamedfindOffsetOfNextZeroOrFF→findOffsetOfNextPacketBoundaryto match what it actually does (private, one call site).
mBtCommandMapOthersobtCommandToStringnames it inthe logs. Deliberately not in
mBtResponseMap—isKnownResponse()must stayfalse for it, or
processResponseCommand()would try to parse it as a responsebody.
LiteProtocolgets the same treatment (it is live —BasicShimmerBluetoothManagerPcinstantiates it). Its NACK value is declared locally because the generated
LiteProtocolInstructionSethas no NACK entry; adding it toLiteProtocolInstructionSet.protoand regenerating would be tidier, but thatrewrites a large generated file committed in two places for the sake of one
constant. Happy to do it that way instead if preferred.
Verification
Compile-verified only.
ShimmerDrivercompiles clean with no new warnings.Note the checked-in
ShimmerDriverGradle wrapper (6.1) cannot build this projectat all in its current state, with or without this change:
grpc-all:1.71.0pulls aguava whose module metadata has
android/jrevariants that Gradle 6.1 cannotdisambiguate, so
:compileJavafails at dependency resolution. I confirmed thatagainst an untouched tree before assuming it was mine, and compiled by forcing
guava to the version the project itself declares. CI is unaffected because
gradle.ymlbuildsShimmerDriverPCwith Gradle 8.10.2, which resolves it fine —so CI here is a genuine check. The stale wrapper looks worth a separate ticket.
No hardware, and no automated test. There is no test harness for the BT receive
path —
ShimmerBluetoothis abstract with a large number of abstract I/O methodsand no existing test instantiates it, so exercising a NACK end-to-end needs a fake
transport that does not exist yet. I did not want to half-build one inside this
change; it is worth its own piece of work, and it would pay for itself well beyond
this fix.
Worth a bench check: configure something while streaming (the firmware NACKs it) and
confirm the connection now survives and the queued commands continue, where
previously it dropped after ~2 s.
Not in scope
The related
writeMemdefect from the tracker ticket is untouched here: theover-range branch at
ShimmerBluetooth.java:4589detects a buffer running past thedevice's memory range and does nothing, its body being entirely commented out, and
MAX_CALIB_DUMP_MAX(4096) disagrees with the firmware'sSHIMMER_CALIB_RAM_MAX(1024 on Shimmer3) so the guard would be wrong even if enabled. Deciding what should
happen on an over-range write is a separate question from teaching the driver to
read a NACK, so I have left it for its own change.
🤖 Generated with Claude Code