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
processNackFromCommand() unwound the transaction but sent no progress report, so an operation containing a refused command never reached its end value. BluetoothProgressReportPerDevice.updateProgress() derives the counter from the remaining instruction stack size, and ShimmerDeviceCallbackAdapter only calls finishOperation() when that counter equals mProgressEndValue - so a single NACK left the operation running indefinitely, with mOperationUnderway still true and the application sitting in CONFIGURING/CONNECTING. This matters most at connect time. With SD sync enabled the firmware NACKs every command except SET_SD_SYNC_COMMAND and ACK (ShimBt_isCmdAllowedWhileSdSyncing), which covers the whole driver initialisation sequence. The report is sent before the refused instruction is removed, and only while it is still queued, to match the arithmetic on the ACK path: the response-wait path already reported when its ACK arrived, so reporting again there would skip a count. The three timer-driven commands the ACK path excludes are excluded here too. Known limitation: a refused command still counts as progress and the operation completes as SUCCESS. Reporting a partially-refused operation as FAIL_WITH_WARNING would mean changing the shared OperationState handling in finishOperation(), which is a wider change than this fix; eventCommandRefused() remains the hook for an application that needs to know. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two earlier harnesses both refused a command while sensing, so both took the mIsStreaming branch of checkForAckOrRespTask - which clears the instruction stack but leaves the connection up. The ~2 s teardown the ticket describes is in the other branch, reached only by a refusal while not streaming, and nothing exercised it. Configuring mid-stream is not a supported operation either, so those runs were testing a path the product does not use. This drives the supported case. With SD sync enabled the firmware refuses every command except SET_SD_SYNC_COMMAND and ACK (ShimBt_isCmdAllowedWhileSdSyncing), so the driver's whole initialisation sequence is NACKed at connect time. On master that ends in connectionLost() about two seconds in; on this branch the refusals are recognised and the link stays up. Nothing is written to the device, so the run is non-destructive - SD sync is set once in Consensys beforehand and cleared afterwards. The test reports INCONCLUSIVE rather than a pass if FULLY_INITIALIZED arrives, which means sync was not actually enabled. Compiled against ShimmerPCBasicExamples with Gradle 8.10, the version CI uses. The checked-in wrapper (6.1) cannot configure ShimmerDriverPC at all - its Shadow plugin requires Gradle 7+ - which is more evidence the stale wrapper deserves its own ticket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.A refused command was indistinguishable from a dead link, and cost the
connection.
Where this actually bites
checkForAckOrRespTaskbranches on whether the device is streaming. TheconnectionLost()teardown above is the not-streaming branch; while streamingthe same timeout instead calls
clearAllInstructions(), which destroys the queuedinstructions but leaves the link up.
Configuring a device mid-stream is not a supported operation — the only commands
sent while sensing are start/stop onboard recording and stop streaming, and
START_LOGGING_ONLY_COMMAND/STOP_LOGGING_ONLY_COMMANDalready have their ownspecial-cased timeout handling in
checkForAckOrRespTaskthat avoidsconnectionLost(). So the not-streaming branch is the one that matters, and anearlier revision of this description had that backwards.
Counting the firmware's refusals in
log-and-stream-common/Comms/shimmer_bt_uart.c,exactly one of ~14
sendNacksites is the sensing gate. The rest are reachablewhile idle:
SET_SD_SYNC_COMMANDandACK839, viaShimBt_isCmdAllowedWhileSdSyncing839args[]852SET_SAMPLING_RATE_COMMANDwith a zero clock divider11561195SET_DAUGHTER_CARD_MEMEEPROM write failure1397SET_CHARGE_STATUS_LED_COMMAND— dispatched but never implemented1408SET_BT_COMMS_BAUD_RATE— not supported1417SET_INFOMEM_COMMANDoffset out of range1502RESET_BT_ERROR_COUNTS1586,1589ShimBt_isCmdBlockedWhileSensing)845— the only streaming oneThe SD sync case is the sharpest:
With sync enabled the firmware refuses the driver's entire initialisation sequence,
so on master a sync-enabled Shimmer cannot be connected to at all — it drops
about two seconds in, with nothing in the log saying why.
The existing coping strategy was avoidance rather than handling, which is decent
evidence the failure mode is real —
readPressureCalibrationCoefficients()skipsthe BMP581 calibration 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, reports progress, 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:
one that matters.
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.but still strictly better than master destroying the whole queue.
Completing the operation (
8bd5b940)Unwinding the transaction was not enough on its own. Operation completion is driven
off the ACK path:
sendProgressReport(...)advances a counter, andShimmerDeviceCallbackAdapteronly callsfinishOperation()oncemProgressCounter == mProgressEndValue.processNackFromCommand()sent noprogress report, so a single refused command inside an operation meant the
operation never completed —
mOperationUnderwaystayed true and the applicationsat in CONFIGURING/CONNECTING indefinitely.
That matters most at connect time, which is exactly where the SD sync refusals
land. Master never completed the operation either, but it killed the connection
first, so the hang was never visible; making the link survive is what exposes it.
The report is sent before the refused instruction is removed, and only while it is
still queued, because
BluetoothProgressReportPerDevice.updateProgress()derivesthe counter from the remaining stack size — the response-wait path already reported
when its ACK arrived, and reporting twice there would skip a count. The same three
timer-driven commands the ACK path excludes are excluded here.
Known limitation: a refused command still counts as progress, so the operation
completes as
SUCCESS. Reporting a partially-refused operation asFAIL_WITH_WARNINGwould mean changing the sharedOperationStatehandling infinishOperation(), which is wider than this fix;eventCommandRefused()remainsthe hook for an application that needs to know.
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
Bench-tested for the streaming case, compile-verified otherwise.
Mas confirmed on real hardware that master never recognises a device NACK (it times
out silently) while this branch detects it, unwinds and advances. That run used the
SET-while-sensing case, so it exercised the streaming branch — which is why the
~2 s connection drop did not reproduce in it, and why a command queued behind the
refused one did not survive on master: the streaming timeout calls
clearAllInstructions()by design.TestNackNotStreaming(586c58db) drives the path that actually matters. With SDsync enabled on the device, every command in the driver's initialisation sequence is
refused, so:
NACK Received for Command:in the log.Nothing is written to the device by the test, so the run is non-destructive; SD sync
is set once in Consensys beforehand and cleared afterwards. If
FULLY_INITIALIZEDarrives the test reports INCONCLUSIVE rather than a pass, since that means sync was
not actually enabled.
ShimmerDriverandShimmerPCBasicExamplesboth compile clean with no newwarnings under Gradle 8.10, the version CI uses.
Note the checked-in Gradle wrapper (6.1) cannot build this project in its current
state, with or without this change, for two independent reasons:
grpc-all:1.71.0pulls a guava whose module metadata has
android/jrevariants that Gradle 6.1cannot disambiguate, and
ShimmerDriverPC's Shadow plugin (7.0.0) refuses to applybelow Gradle 7. I confirmed both against an untouched tree before assuming they were
mine. CI is unaffected because
gradle.ymluses Gradle 8.10.2, so CI here is agenuine check. The stale wrapper looks worth a separate ticket.
No automated test. There is no test harness for the BT receive path —
ShimmerBluetoothis abstract with a large number of abstract I/O methods and noexisting 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.
Sibling implementations
The same wire format is implemented independently in several other places, so this was
checked against all of them rather than assumed to be Java-only. Three carried the same
defect and now have their own PRs:
Shimmer-C-API0xFEfalls to the switch default, reported as "Misaligned ByteStream Detected" while streaming, then disconnects after ~10 s of read timeoutsSwiftAPICheckedContinuationis never resumed, andtimeoutInSecondsis declared but never used, so the caller'sawaitnever returns at allpyshimmer_run_readloop, which catches onlyReadAbort, killing the reader thread and blocking every waiter for goodshimmer-web-sdkdevices/shimmer3/protocol.tsdefinesNACK = 0xFE, andShimmer3Clientgates the framing on a command genuinely being in flightShimmer-MATLAB-ID,Shimmer-Labview-API,Shimmer-Advance-APINothing links these repos and nothing tests them together, so the timing differs wildly
by accident: two seconds here, ten in C#, never in Swift and Python.
Not in scope
The related
writeMemdefect from the tracker ticket is untouched here: theover-range branch in
writeMem()detects a buffer running past the device's memoryrange 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