From b6842e6e918ada248ffb994a1b894c6f7d79d43c Mon Sep 17 00:00:00 2001 From: Mark Nolan Date: Thu, 3 Sep 2026 14:01:22 +0100 Subject: [PATCH 1/4] DEV-982: handle NACK from a Shimmer3/3R instead of dropping the connection 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 --- .../bluetooth/ShimmerBluetooth.java | 106 ++++++++++++++++-- .../comms/radioProtocol/LiteProtocol.java | 50 +++++++++ .../shimmerresearch/driver/ShimmerObject.java | 1 + 3 files changed, 149 insertions(+), 8 deletions(-) diff --git a/ShimmerDriver/src/main/java/com/shimmerresearch/bluetooth/ShimmerBluetooth.java b/ShimmerDriver/src/main/java/com/shimmerresearch/bluetooth/ShimmerBluetooth.java index 18bfc999c..6f3dd7ebf 100644 --- a/ShimmerDriver/src/main/java/com/shimmerresearch/bluetooth/ShimmerBluetooth.java +++ b/ShimmerDriver/src/main/java/com/shimmerresearch/bluetooth/ShimmerBluetooth.java @@ -305,6 +305,7 @@ public enum SHIMMER_FEATURE { aMap.put(DATA_PACKET, new BtCommandDetails(DATA_PACKET, "DATA_PACKET")); aMap.put(ROUTINE_COMMUNICATION, new BtCommandDetails(ROUTINE_COMMUNICATION, "ROUTINE_COMMUNICATION")); aMap.put(ACK_COMMAND_PROCESSED, new BtCommandDetails(ACK_COMMAND_PROCESSED, "ACK_COMMAND_PROCESSED")); + aMap.put(NACK_COMMAND_PROCESSED, new BtCommandDetails(NACK_COMMAND_PROCESSED, "NACK_COMMAND_PROCESSED")); mBtCommandMapOther = Collections.unmodifiableMap(aMap); } @@ -866,6 +867,14 @@ else if(isKnownGetCommand(mCurrentCommand)){ } } + /* The Shimmer refused the command. Without this branch the NACK + * was discarded, mWaitForAck stayed true and the instruction stack + * stayed locked until the ACK timer expired and tore the + * connection down - a refusal was indistinguishable from a dead + * link. */ + else if((byte)byteBuffer[0]==NACK_COMMAND_PROCESSED) { + processNackFromCommand(); + } } } } @@ -877,8 +886,15 @@ private void processNotStreamingWaitForResp() { if(byteBuffer!=null){ setIamAlive(true); + /* A NACK can arrive here rather than in the ACK state if the + * firmware decides it cannot serve a GET only while building the + * response. Checked before isKnownResponse() so it is handled as a + * refusal rather than falling through to the ACK timeout. */ + if((byte)byteBuffer[0]==NACK_COMMAND_PROCESSED){ + processNackFromCommand(); + } //Check to see whether it is a response byte - if(isKnownResponse(byteBuffer[0])){ + else if(isKnownResponse(byteBuffer[0])){ byte responseCommand = byteBuffer[0]; processResponseCommand(responseCommand); @@ -918,7 +934,14 @@ && bytesAvailableToBeRead()) { byteBuffer=readBytes(1); if(byteBuffer!=null){ - if(byteBuffer[0]==ACK_COMMAND_PROCESSED) { + /* Deliberately log-only: this path runs with no command in + * flight (!mWaitForAck && !mWaitForResponse), so there is no + * transaction to fail and a stray 0xFE must not be turned into a + * refusal. The buffer is cleared below either way. */ + if(byteBuffer[0]==NACK_COMMAND_PROCESSED) { + printLogDataForDebugging("NACK received with no command awaiting a reply - ignored"); + } + else if(byteBuffer[0]==ACK_COMMAND_PROCESSED) { printLogDataForDebugging("ACK RECEIVED , Connected State!!"); byteBuffer = readBytes(1, INSTREAM_CMD_RESPONSE); if(byteBuffer!=null && byteBuffer[0]==ACK_COMMAND_PROCESSED){ //an android fix.. not fully investigated (JC) @@ -1014,6 +1037,25 @@ else if(isSupportedInStreamCmds() && bufferTemp[getPacketSizeWithCrc()+2]==INSTR } } + //Data packet followed by a NACK (a command refused while streaming, e.g. + //any SET blocked by the firmware because it is sensing) + else if(bufferTemp[0]==DATA_PACKET + && bufferTemp[getPacketSizeWithCrc()+1]==NACK_COMMAND_PROCESSED){ + + if (mBtCommsCrcModeCurrent != BT_CRC_MODE.OFF && !checkCrc(bufferTemp, getPacketSize() + 1)) { + discardBufferBytesToNextPacket(); + return; + } + + //Handle the data packet first, then fail the command in flight + processDataPacket(bufferTemp); + processNackFromCommand(); + + /* clearBuffers() rather than clearSingleDataPacketFromBuffers(), + * because the NACK is a single byte with no payload behind it and that + * helper would push it back as the next packet header. */ + clearBuffers(); + } //TODO: ACK in bufferTemp[0] not handled //else if else { @@ -1303,13 +1345,14 @@ protected void clearBuffers() { } /** - * Next packet start should begin with DATA_PACKET or ACK_COMMAND_PROCESSED byte so skip to that point + * Next packet start should begin with DATA_PACKET, ACK_COMMAND_PROCESSED or + * NACK_COMMAND_PROCESSED byte so skip to that point */ protected void discardBufferBytesToNextPacket(){ byte[] bTemp = mByteArrayOutputStream.toByteArray(); //Find index of first DATA_PACKET or ACK byte within the buffer - int offset = findOffsetOfNextZeroOrFF(bTemp); + int offset = findOffsetOfNextPacketBoundary(bTemp); //If not found, just skip one byte offset = (offset == -1) ? 1 : offset; @@ -1323,15 +1366,16 @@ protected void discardBufferBytesToNextPacket(){ } /** - * Finds the offset/index of the next DATA_PACKET (0x00) or ACK_COMMAND_PROCESSED (0xFF) byte. + * Finds the offset/index of the next DATA_PACKET (0x00), ACK_COMMAND_PROCESSED + * (0xFF) or NACK_COMMAND_PROCESSED (0xFE) byte. * * @param buffer a byte array to search within - * @return index of the first 0x00 or 0xFF byte found after position 0, or -1 if not found + * @return index of the first 0x00, 0xFF or 0xFE byte found after position 0, or -1 if not found */ - private static int findOffsetOfNextZeroOrFF(byte[] buffer) { + private static int findOffsetOfNextPacketBoundary(byte[] buffer) { for (int i = 1; i < buffer.length; i++) { byte b = buffer[i]; - if (b == 0 || b == (byte) 0xFF) + if (b == 0 || b == ACK_COMMAND_PROCESSED || b == NACK_COMMAND_PROCESSED) return i; } return -1; @@ -1903,6 +1947,52 @@ else if(responseCommand==BT_FW_VERSION_STR_RESPONSE) { } } + /** + * Unwinds the in-flight transaction after the Shimmer answered a command + * with NACK (0xFE) instead of ACK. + * + *

The refused instruction is dropped and the stack advances, exactly as + * it would on an ACK, so one refused command no longer stalls everything + * queued behind it. The connection is left up: a refusal means the device + * declined this command, not that the link is gone. + */ + private void processNackFromCommand() { + stopTimerCheckForAckOrResp(); //cancel the ack timer + printLogDataForDebugging("NACK Received for Command: \t\t" + btCommandToString(mCurrentCommand)); + + 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(); + + mTransactionCompleted=true; + setInstructionStackLock(false); + + eventCommandRefused(refusedCommand); + } + + /** + * Called when the Shimmer refused a command with a NACK. The transaction has + * already been unwound and the connection is still up. + * + *

Deliberately concrete rather than abstract so that existing subclasses + * keep compiling; override to surface the refusal to the application. A + * refusal is expected in normal use - most SET commands are rejected while + * the device is sensing, and some GETs are rejected for hardware that does + * not have the feature. + * + * @param command the command byte that was refused + */ + protected void eventCommandRefused(byte command) { + //Default: no application-level notification, the log line above stands + } + // TODO: Consider removing this, replace by SET then GET and let the // RESPONSE update the variables in ShimmerObject - removes duplication of // code and ensure ShimmerObject it up-to-date with exactly what is on diff --git a/ShimmerDriver/src/main/java/com/shimmerresearch/comms/radioProtocol/LiteProtocol.java b/ShimmerDriver/src/main/java/com/shimmerresearch/comms/radioProtocol/LiteProtocol.java index dc1634651..63ac6f93a 100644 --- a/ShimmerDriver/src/main/java/com/shimmerresearch/comms/radioProtocol/LiteProtocol.java +++ b/ShimmerDriver/src/main/java/com/shimmerresearch/comms/radioProtocol/LiteProtocol.java @@ -126,6 +126,12 @@ public class InstructionsGet{ protected String mDirectoryName; protected int numBytesToReadFromExpBoard=0; private static final int MAX_CALIB_DUMP_MAX = 4096; + /* The generated LiteProtocolInstructionSet has no NACK entry, so the value + * is declared here rather than read from InstructionsSet. Adding it to + * grpcprotosrc/src/LiteProtocolInstructionSet.proto and regenerating would + * be tidier, but that rewrites a large generated file committed in two + * places for the sake of one constant. */ + private static final int NACK_COMMAND_PROCESSED_VALUE = 0xFE; /** @@ -606,6 +612,12 @@ else if(isKnownGetCommand(mCurrentCommand)){ } } + /* 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(); + } } } } @@ -1295,6 +1307,44 @@ private int availableBytes() throws ShimmerException { public void eventLogAndStreamStatusChanged(int currentCommand){ mProtocolListener.eventLogAndStreamStatusChangedCallback(currentCommand); } + + /** + * Unwinds the in-flight transaction after the Shimmer answered a command + * with NACK (0xFE) instead of ACK. The refused instruction is dropped so the + * stack advances, and the connection is left up - a refusal means the device + * declined this command, not that the link is gone. + */ + private void processNackFromCommand() { + stopTimerCheckForAckOrResp(); //cancel the ack timer + printLogDataForDebugging("NACK Received for Command: \t\t" + btCommandToString(mCurrentCommand)); + + int refusedCommand = ((int)mCurrentCommand)&0xFF; + + mWaitForAck=false; + mWaitForResponse=false; + + if(getListofInstructions().size()>0){ + getListofInstructions().remove(0); + } + getListofInstructions().removeAll(Collections.singleton(null)); + + mTransactionCompleted=true; + setInstructionStackLock(false); + + eventCommandRefused(refusedCommand); + } + + /** + * Called when the Shimmer refused a command with a NACK. The transaction has + * already been unwound and the connection is still up. Concrete rather than + * a new ProtocolListener callback so that existing implementers keep + * compiling; override to surface the refusal to the application. + * + * @param command the command value that was refused + */ + protected void eventCommandRefused(int command) { + //Default: no application-level notification, the log line above stands + } private void isNowStreaming() { mProtocolListener.isNowStreaming(); diff --git a/ShimmerDriver/src/main/java/com/shimmerresearch/driver/ShimmerObject.java b/ShimmerDriver/src/main/java/com/shimmerresearch/driver/ShimmerObject.java index 4a6808c69..b2a435524 100644 --- a/ShimmerDriver/src/main/java/com/shimmerresearch/driver/ShimmerObject.java +++ b/ShimmerDriver/src/main/java/com/shimmerresearch/driver/ShimmerObject.java @@ -457,6 +457,7 @@ public class BTStream { public static final byte GET_RWC_COMMAND = (byte) 0x91; public static final byte ROUTINE_COMMUNICATION = (byte) 0xE0; + public static final byte NACK_COMMAND_PROCESSED = (byte) 0xFE; public static final byte ACK_COMMAND_PROCESSED = (byte) 0xFF; public static final byte START_LOGGING_ONLY_COMMAND = (byte) 0x92; From 648897ba72a514a177f763cff11ef8deb94fde49 Mon Sep 17 00:00:00 2001 From: Mark Nolan Date: Thu, 3 Sep 2026 14:47:08 +0100 Subject: [PATCH 2/4] DEV-982: only drop the queued instruction when it is still queued (Copilot 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 --- .../bluetooth/ShimmerBluetooth.java | 24 ++++++++++++------- .../comms/radioProtocol/LiteProtocol.java | 17 +++++++++++-- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/ShimmerDriver/src/main/java/com/shimmerresearch/bluetooth/ShimmerBluetooth.java b/ShimmerDriver/src/main/java/com/shimmerresearch/bluetooth/ShimmerBluetooth.java index 6f3dd7ebf..55132210e 100644 --- a/ShimmerDriver/src/main/java/com/shimmerresearch/bluetooth/ShimmerBluetooth.java +++ b/ShimmerDriver/src/main/java/com/shimmerresearch/bluetooth/ShimmerBluetooth.java @@ -1351,7 +1351,7 @@ protected void clearBuffers() { protected void discardBufferBytesToNextPacket(){ byte[] bTemp = mByteArrayOutputStream.toByteArray(); - //Find index of first DATA_PACKET or ACK byte within the buffer + //Find index of first DATA_PACKET, ACK or NACK byte within the buffer int offset = findOffsetOfNextPacketBoundary(bTemp); //If not found, just skip one byte offset = (offset == -1) ? 1 : offset; @@ -1375,7 +1375,7 @@ protected void discardBufferBytesToNextPacket(){ private static int findOffsetOfNextPacketBoundary(byte[] buffer) { for (int i = 1; i < buffer.length; i++) { byte b = buffer[i]; - if (b == 0 || b == ACK_COMMAND_PROCESSED || b == NACK_COMMAND_PROCESSED) + if (b == DATA_PACKET || b == ACK_COMMAND_PROCESSED || b == NACK_COMMAND_PROCESSED) return i; } return -1; @@ -1951,10 +1951,12 @@ else if(responseCommand==BT_FW_VERSION_STR_RESPONSE) { * Unwinds the in-flight transaction after the Shimmer answered a command * with NACK (0xFE) instead of ACK. * - *

The refused instruction is dropped and the stack advances, exactly as - * it would on an ACK, so one refused command no longer stalls everything - * queued behind it. The connection is left up: a refusal means the device - * declined this command, not that the link is gone. + *

The refused instruction is dropped and the stack advances, as it would + * on an ACK, so one refused command no longer stalls everything queued + * behind it. It is dropped only when it is still queued - a GET's + * instruction is already gone by the time its response is awaited. The + * connection is left up: a refusal means the device declined this command, + * not that the link is gone. */ private void processNackFromCommand() { stopTimerCheckForAckOrResp(); //cancel the ack timer @@ -1962,11 +1964,17 @@ private void processNackFromCommand() { byte refusedCommand = mCurrentCommand; + /* Only the ACK-wait state still has the refused instruction queued: a + * GET's instruction is removed as soon as its ACK arrives, so removing + * one here as well would drop whatever was queued behind it and silently + * skip that command. */ + boolean instructionStillQueued = mWaitForAck; + mWaitForAck=false; mWaitForResponse=false; - //Drop the refused instruction, as both ACK paths do for their command - if(getListofInstructions().size()>0){ + //Drop the refused instruction, as the ACK path does for its own command + if(instructionStillQueued && getListofInstructions().size()>0){ removeInstruction(0); } removeAllNulls(); diff --git a/ShimmerDriver/src/main/java/com/shimmerresearch/comms/radioProtocol/LiteProtocol.java b/ShimmerDriver/src/main/java/com/shimmerresearch/comms/radioProtocol/LiteProtocol.java index 63ac6f93a..56dddebf3 100644 --- a/ShimmerDriver/src/main/java/com/shimmerresearch/comms/radioProtocol/LiteProtocol.java +++ b/ShimmerDriver/src/main/java/com/shimmerresearch/comms/radioProtocol/LiteProtocol.java @@ -652,8 +652,16 @@ else if(bytesAvailableToBeRead()){ byteBuffer=readBytes(1); mIamAlive = true; + /* As in the ACK-wait path above, and matching ShimmerBluetooth: a + * NACK can land here instead if the firmware only discovers it + * cannot serve a GET while building the response. Checked before + * isKnownResponseByte() so it is handled as a refusal rather than + * falling through to the ACK/response timeout. */ + if((((int)byteBuffer[0])&0xFF)==NACK_COMMAND_PROCESSED_VALUE){ + processNackFromCommand(); + } //Check to see whether it is a response byte - if(isKnownResponseByte(byteBuffer[0])){ + else if(isKnownResponseByte(byteBuffer[0])){ byte responseCommand = byteBuffer[0]; if(mUseShimmerBluetoothApproach){ @@ -1320,10 +1328,15 @@ private void processNackFromCommand() { int refusedCommand = ((int)mCurrentCommand)&0xFF; + /* Only the ACK-wait state still has the refused instruction queued: a + * GET's instruction is removed as soon as its ACK arrives, so removing + * one here as well would drop whatever was queued behind it. */ + boolean instructionStillQueued = mWaitForAck; + mWaitForAck=false; mWaitForResponse=false; - if(getListofInstructions().size()>0){ + if(instructionStillQueued && getListofInstructions().size()>0){ getListofInstructions().remove(0); } getListofInstructions().removeAll(Collections.singleton(null)); From 8bd5b940deafc08c8bb93cf0ed5d1362370c5047 Mon Sep 17 00:00:00 2001 From: Mark Nolan Date: Mon, 21 Sep 2026 09:21:08 +0100 Subject: [PATCH 3/4] DEV-982: complete the operation when a command is refused 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 --- .../bluetooth/ShimmerBluetooth.java | 18 ++++++++++++++++++ .../comms/radioProtocol/LiteProtocol.java | 13 +++++++++++++ 2 files changed, 31 insertions(+) diff --git a/ShimmerDriver/src/main/java/com/shimmerresearch/bluetooth/ShimmerBluetooth.java b/ShimmerDriver/src/main/java/com/shimmerresearch/bluetooth/ShimmerBluetooth.java index 55132210e..ea61763e7 100644 --- a/ShimmerDriver/src/main/java/com/shimmerresearch/bluetooth/ShimmerBluetooth.java +++ b/ShimmerDriver/src/main/java/com/shimmerresearch/bluetooth/ShimmerBluetooth.java @@ -1973,6 +1973,24 @@ private void processNackFromCommand() { mWaitForAck=false; mWaitForResponse=false; + /* Mirror the ACK path's progress report so that an operation containing a + * refused command can still reach its end value. + * BluetoothProgressReportPerDevice.updateProgress() derives the counter + * from the remaining stack size, so this has to be sent before the + * instruction is removed, and only while it is still queued - the + * response-wait path already reported when its ACK arrived and reporting + * twice would skip a count. Without it the counter never reaches + * mProgressEndValue, finishOperation() never fires, and the application + * stays in CONFIGURING/CONNECTING indefinitely. The same three + * timer-driven commands are excluded as on the ACK path. */ + if(instructionStillQueued + && mCurrentCommand!=GET_STATUS_COMMAND + && mCurrentCommand!=TEST_CONNECTION_COMMAND + && mCurrentCommand!=SET_BLINK_LED + && mOperationUnderway){ + sendProgressReport(new BluetoothProgressReportPerCmd(mCurrentCommand, getListofInstructions().size(), mMyBluetoothAddress, getComPort())); + } + //Drop the refused instruction, as the ACK path does for its own command if(instructionStillQueued && getListofInstructions().size()>0){ removeInstruction(0); diff --git a/ShimmerDriver/src/main/java/com/shimmerresearch/comms/radioProtocol/LiteProtocol.java b/ShimmerDriver/src/main/java/com/shimmerresearch/comms/radioProtocol/LiteProtocol.java index 56dddebf3..1ba5b1668 100644 --- a/ShimmerDriver/src/main/java/com/shimmerresearch/comms/radioProtocol/LiteProtocol.java +++ b/ShimmerDriver/src/main/java/com/shimmerresearch/comms/radioProtocol/LiteProtocol.java @@ -1336,6 +1336,19 @@ private void processNackFromCommand() { mWaitForAck=false; mWaitForResponse=false; + /* Mirror the ACK path's progress report so that an operation containing a + * refused command can still reach its end value, otherwise the progress + * counter stalls and the operation never completes. Sent before the + * instruction is removed and only while it is still queued, because the + * counter is derived from the remaining stack size. */ + if(instructionStillQueued + && mCurrentCommand!=InstructionsGet.GET_STATUS_COMMAND_VALUE + && mCurrentCommand!=InstructionsSet.TEST_CONNECTION_COMMAND_VALUE + && mCurrentCommand!=InstructionsSet.SET_BLINK_LED_VALUE + && mOperationUnderway){ + sendProgressReport(new BluetoothProgressReportPerCmd(mCurrentCommand, getListofInstructions().size(), mMyBluetoothAddress, mComPort)); + } + if(instructionStillQueued && getListofInstructions().size()>0){ getListofInstructions().remove(0); } From 586c58dba2bb1247015da945a9cdeabd8c300609 Mon Sep 17 00:00:00 2001 From: Mark Nolan Date: Mon, 21 Sep 2026 09:28:58 +0100 Subject: [PATCH 4/4] DEV-982: add a bench test for the non-streaming NACK path 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 --- .../simpleexamples/TestNackNotStreaming.java | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 ShimmerPCBasicExamples/src/main/java/com/shimmerresearch/simpleexamples/TestNackNotStreaming.java diff --git a/ShimmerPCBasicExamples/src/main/java/com/shimmerresearch/simpleexamples/TestNackNotStreaming.java b/ShimmerPCBasicExamples/src/main/java/com/shimmerresearch/simpleexamples/TestNackNotStreaming.java new file mode 100644 index 000000000..050de7805 --- /dev/null +++ b/ShimmerPCBasicExamples/src/main/java/com/shimmerresearch/simpleexamples/TestNackNotStreaming.java @@ -0,0 +1,196 @@ +package com.shimmerresearch.simpleexamples; + +import java.util.ArrayList; +import java.util.List; + +import com.shimmerresearch.bluetooth.ShimmerBluetooth; +import com.shimmerresearch.bluetooth.ShimmerBluetooth.BT_STATE; +import com.shimmerresearch.driver.BasicProcessWithCallBack; +import com.shimmerresearch.driver.CallbackObject; +import com.shimmerresearch.driver.ShimmerMsg; +import com.shimmerresearch.tools.bluetooth.BasicShimmerBluetoothManagerPc; + +/** + * DEV-982 / PR #293 - exercises the NACK path that the earlier streaming tests + * could not reach. + * + *

Why the streaming tests did not show the connection drop

+ * + * {@code checkForAckOrRespTask} in ShimmerBluetooth branches on whether the + * device is streaming: + * + *
+ * if(mIsStreaming){
+ *     ... clearAllInstructions();   // connection stays UP, queue is destroyed
+ * }
+ * else {
+ *     ... connectionLost();         // the ~2 s drop described in the ticket
+ * }
+ * 
+ * + * TestNackWhileStreaming and TestNackQueuedCommand both refuse a command while + * sensing, so both take the first branch - which on master never drops the + * connection. The ~2 s teardown lives only in the second branch, and only a + * refusal while NOT streaming reaches it. That is what this test does. + * + * Configuring a device mid-stream is also not a supported operation, so those + * two tests were exercising a path the product does not use. + * + *

How this test forces a NACK

+ * + * The firmware gates every command before dispatching it + * ({@code ShimBt_processCmd} in log-and-stream-common/Comms/shimmer_bt_uart.c): + * + *
+ * if (storedConfigPtr->syncEnable ^ ShimBt_isCmdAllowedWhileSdSyncing(gAction))
+ *     sendNack = 1;
+ * 
+ * + * and the allow-list is only two entries: + * + *
+ * uint8_t ShimBt_isCmdAllowedWhileSdSyncing(uint8_t command)
+ * {
+ *   return (command == SET_SD_SYNC_COMMAND || command == ACK_COMMAND_PROCESSED);
+ * }
+ * 
+ * + * So with SD sync enabled the device refuses everything else, including + * the whole driver initialisation sequence (GET_FW_VERSION, INQUIRY, + * GET_STATUS ...). Nothing is written to the device to set this up and nothing + * is written by the test, so the run is non-destructive. + * + *

Setup

+ * + *
    + *
  1. In Consensys, enable SD sync on the Shimmer, and write the configuration.
  2. + *
  3. Set {@link #COM_PORT} below.
  4. + *
  5. Run this class on {@code master}, then on the PR #293 branch, unchanged.
  6. + *
  7. Afterwards, disable SD sync again in Consensys.
  8. + *
+ * + *

What to expect

+ * + * + * + * Note that with sync enabled the device cannot finish initialising on either + * branch - every GET it needs is refused. That is expected and is not what is + * being measured here. The difference under test is how it fails: a + * silent link teardown, or a logged refusal with the connection intact. + * + * If FULLY_INITIALIZED arrives, SD sync was not actually enabled and the run + * proves nothing - the test says so rather than reporting a pass. + */ +public class TestNackNotStreaming extends BasicProcessWithCallBack { + + /** TODO set this to your device's COM port */ + private static final String COM_PORT = "COM18"; + + /** + * Comfortably longer than ACK_TIMER_DURATION (2 s) plus the driver's retry + * allowance, so a teardown has every chance to happen before we judge. + */ + private static final long OBSERVATION_WINDOW_MS = 30000; + + private BasicShimmerBluetoothManagerPc mBtManager; + private long mTimeOfConnect; + + private volatile boolean mSawConnectionTeardown = false; + private volatile boolean mSawFullyInitialised = false; + private final List mTransitions = new ArrayList(); + + public static void main(String[] args) throws Exception { + new TestNackNotStreaming().run(); + } + + private void run() throws Exception { + mBtManager = new BasicShimmerBluetoothManagerPc(); + setWaitForData(mBtManager.callBackObject); + + log("Connecting to " + COM_PORT + " - SD sync must be ENABLED on this device"); + mTimeOfConnect = System.currentTimeMillis(); + mBtManager.connectShimmerThroughCommPort(COM_PORT); + + long deadline = mTimeOfConnect + OBSERVATION_WINDOW_MS; + while (System.currentTimeMillis() < deadline && !mSawConnectionTeardown) { + Thread.sleep(250); + } + + report(); + + try { + mBtManager.disconnectAllDevices(); + } catch (Exception e) { + //Nothing useful to do here - the run is over either way + } + System.exit(0); + } + + @Override + protected void processMsgFromCallback(ShimmerMsg shimmerMSG) { + if (!(shimmerMSG.mB instanceof CallbackObject)) { + return; + } + CallbackObject cbo = (CallbackObject) shimmerMSG.mB; + + if (shimmerMSG.mIdentifier == ShimmerBluetooth.MSG_IDENTIFIER_STATE_CHANGE) { + record("BT_STATE -> " + cbo.mState); + if (cbo.mState == BT_STATE.CONNECTION_LOST + || cbo.mState == BT_STATE.CONNECTION_FAILED + || cbo.mState == BT_STATE.DISCONNECTED) { + mSawConnectionTeardown = true; + } + } + else if (shimmerMSG.mIdentifier == ShimmerBluetooth.MSG_IDENTIFIER_NOTIFICATION_MESSAGE) { + if (cbo.mIndicator == ShimmerBluetooth.NOTIFICATION_SHIMMER_FULLY_INITIALIZED) { + record("FULLY_INITIALIZED"); + mSawFullyInitialised = true; + } + } + } + + private void report() { + log(""); + log("---------------- observed ----------------"); + for (String t : mTransitions) { + log(t); + } + log("------------------------------------------"); + + if (mSawFullyInitialised) { + log("INCONCLUSIVE - the device initialised, so SD sync was not enabled."); + log(" Enable SD sync in Consensys and run this again."); + } + else if (mSawConnectionTeardown) { + log("MASTER BEHAVIOUR - the link was torn down after the refusal."); + log(" This is the DEV-982 defect: a refused command"); + log(" is indistinguishable from a dead link."); + } + else { + log("PR #293 BEHAVIOUR - the link survived the refusals for " + + (OBSERVATION_WINDOW_MS / 1000) + " s."); + log(" Check the driver log for 'NACK Received for Command:'"); + log(" to confirm the refusals were actually seen."); + } + } + + private void record(String event) { + String line = String.format("t+%5d ms %s", + System.currentTimeMillis() - mTimeOfConnect, event); + mTransitions.add(line); + log(line); + } + + private static void log(String msg) { + System.out.println(msg); + } +}