feat(device): confirming variants for the silent administration commands - #455
Conversation
…nds (from #344 bench evidence) The ADC-calibration and voltage-precision commands on IStreamingDevice are fire-and-forget: they send a SCPI primitive and parse no reply, so a device that refuses the command is indistinguishable from one that carried it out. Bench evidence on a real Nyquist running fw 3.7.2 (posted on #344): on a unit whose user calibration bank was never written, `CONFigure:ADC:LOADcal` answers `-200,"Execution error"` — and `LoadAdcCalibration()` returns normally. A control run sending the identical raw primitive produced the same -200, so the firmware is behaving as designed and Core's delegation is faithful; what was missing was any way for the caller to find out. "Load the user calibration bank" was a silent no-op that reported success. Adds IConfirmingDeviceAdministration: a confirming `...Async` twin for each of the nine commands, which sends the same primitive and then reads the device's SCPI error queue, throwing DeviceCommandFailedException unless the device confirms it accepted the command. The exception carries the device's own code and line; a null ErrorCode distinguishes "the outcome is unknown" (no readable verdict came back) from "the device said no", since neither is a success but they call for different responses. The queue is drained before the command is sent. Without that the entry popped afterwards could belong to any earlier command — the same trap already documented on the SD listing's terminator, which is why that one is read as a liveness marker and never classified. Additive throughout. The void commands are untouched, on the wire and in signature; the confirming ones live on a separate interface rather than as new IStreamingDevice members so existing implementers keep compiling, following the same capability-slice shape as INetworkConfigurable and ISdCardOperations. The void commands' docs now state the silence explicitly and point at their twin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR Summary by QodoAdd confirming async admin commands with SCPI error-queue verification
AI Description
Diagram
High-Level Assessment
Files changed (12)
|
Code Review by Qodo
1.
|
…ead (Qodo review) Two fixes from review of the confirming administration commands. The confirming exchange left completionTimeoutMs at its 250ms default while raising only responseTimeoutMs. That window is the wrong one to leave alone: ExecuteTextCommandAsync switches to it as soon as *any* line arrives, so on a device that echoes commands the echo starts the clock and a verdict trailing it by more than 250ms would be missed — failing a command the device had actually accepted. Sets an explicit 1000ms, the same value and the same reasoning as the SD listing's SD_LIST_COMPLETION_TIMEOUT_MS, whose terminator is this very query. The response timeout still covers the NVM-write case, where the device says nothing at all until the write is done. ThrowIfNotAccepted discarded TryExtractErrorCode's bool and always threw the integer overload, so an ERROR line carrying no readable code surfaced as ErrorCode 0 — the one value SCPI reserves for "no error". Those lines are reachable: a bare "ERROR", "**ERROR", and "ERROR: <non-numeric>" all classify as SCPI errors while yielding no code. They now take the null-code path with the raw line preserved. That made the null-code constructor's message wrong for the new case, since it assumed "nothing came back". The message is now built from whether a device line exists, and the exception's documentation describes the three states callers can actually observe: code present (refused, reason known), no code but a DeviceResponse (answered, nothing readable in it), neither (outcome unknown). Tests: the exchange's requested timeouts are now asserted; codeless ERROR lines are covered, with a companion test pinning that they really do classify as SCPI errors while yielding no code, so the null-code path stays reachable rather than theoretical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Addressed both findings in 134a8a2 (replies on the inline threads). Branch is rebased onto the updated base, and the full suite is now green on net9.0 and net10.0 — 2777 passed, 0 failed (the 5 /agentic_review |
|
/agentic_review |
Self-review follow-up to the previous commit, which documented ErrorCode as never 0 so callers could branch on it — while leaving one path that could still produce it. TryExtractErrorCode succeeding says nothing about the code being non-zero, so a volunteered `**ERROR: 0,"No error"` was thrown as a refusal carrying code 0. That is worse than the documentation being wrong: 0 is "no error", so a device answering the queue read in ERROR-prefixed shape rather than the bare form would have been reported as rejecting a command it had accepted. A parsed code of 0 is now not treated as evidence of a refusal at all; the bare queue verdict decides. Codeless ERROR lines keep taking the null-code path. ErrorCode is now genuinely never 0 from any path, which is what makes a non-null code mean a real refusal. This firmware uses the bare form, so the case is defensive rather than observed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Code review by qodo was updated up to the latest commit 134a8a2 |
…ctor (Qodo review)
The previous commit stopped the confirming path from ever passing 0, but left
the public (string, int, string) constructor free to accept it — so the
documented "ErrorCode is never 0" guarantee held only for the paths this
assembly happens to take, not for the type.
The constructor now records 0 as ErrorCode null ("no readable code") and builds
the matching message, so the contract is a property of the type rather than a
convention of its callers. DeviceResponse is preserved, so nothing the device
said is lost.
Normalised rather than rejected with ArgumentOutOfRangeException, which was the
suggested fix. Exception constructors run on failure paths, and one that throws
would replace a diagnosable device failure with an argument error — losing the
diagnosis, which is precisely the failure mode this type exists to prevent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Round 3: finding 3 ("Zero ErrorCode allowed") addressed in b88828d — normalised at the constructor rather than guarded with Note the review above was generated against 134a8a2; b89d981 and b88828d have landed since. Suite green on net9.0 and net10.0 — 2782 passed, 0 failed. /agentic_review |
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit b88828d |
…ode unreadable (Qodo review) Two follow-ups on the code-0 handling added in b89d981/b88828d. An error-shaped line reporting code 0 is correctly not treated as a refusal, but it was then dropped: with no bare verdict alongside it, the failure threw with DeviceResponse null and a message saying the error queue was not readable — reporting silence from a device that had plainly answered, and discarding the one diagnostic that would explain why the verdict was not recognised. That line is now carried through and reported. The code-0 message also claimed the response "carries no readable SCPI error code", which is wrong about a line whose code is right there. It says 0, which means "no error" and therefore cannot describe a refusal — a different thing from being unreadable, and now worded as such. The existing test only asserted that 0 was not reported as the ErrorCode, which is why neither showed up: it never looked at what happened to the line or what the message said about it. It now pins the line surviving into DeviceResponse and the message, and that the message does not call the code unreadable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 062043e |
…mands Bench pass on the real Nyquist (fw 3.7.2, USB CDC, non-destructive) validated the confirming path end to end, and produced two numbers the code was only reasoning about. The completion window: this firmware does not echo commands, and every line it has to say arrives within ~20ms of the first — a refusal's two lines land essentially together. So 1000ms is pure trailing latency here and 250ms would have done. It stays at 1000ms as headroom for what the bench could not cover — WiFi, whose gaps are the documented reason the SD listing raised its own window, and the NVM writers, which are destructive to a calibrated unit and were never sent. Both the measurement and the reason for keeping the value are recorded so a future tightening has evidence rather than another analogy. The cost: about 3 seconds per confirming command (drain exchange plus confirming exchange). The interface already warned this was not free without saying how much; a caller looping over 16 channels deserves the actual figure. Behaviour unchanged — documentation only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bench evidence: confirming admin commands on real hardware — 8/8Ran the confirming path against the bench Nyquist (fw 3.7.2, SN 9090539562006014104, USB CDC, Non-destructive by construction. Only queries, the two RAM restores ( Writing the user bank was avoided for a second reason: it would have destroyed the The motivating case, end to end
The false-positive check
Attribution: the pre-send drainSeeded a stale What the wire actually carriesA refusal produces both forms, and the classifier's two paths turn out to correspond to real firmware behaviour rather than to my assumptions: A success produces one line, The measurement that was previously a guess
Kept at 1000 ms anyway, and I want to be explicit that this is a judgement call rather than something the bench endorsed. It is headroom for the two cases this pass could not cover: WiFi, whose gaps are the documented reason the SD listing raised its own window, and the NVM writers, which are destructive to a calibrated unit. Tightening it is a reasonable follow-up once either has evidence. Both the measurement and the reason for keeping the value are now recorded in the constant's docs (b736241), along with the ~3 s cost on the interface — it warned that confirmation was not free without ever saying how much. Bench only — no device state changed. Harness was a throwaway console app built against this branch's Core. |
What
The ADC-calibration and voltage-precision commands on
IStreamingDeviceare fire-and-forget: they send a SCPI primitive and parse no reply. A device that refuses the command is indistinguishable from one that carried it out.This adds
IConfirmingDeviceAdministration— a confirming...Asynctwin for each of the nine commands that sends the same primitive and then reads the device's SCPI error queue, throwingDeviceCommandFailedExceptionunless the device confirms it accepted the command.Why
Bench evidence on a real Nyquist running fw 3.7.2 (posted on #344): on a unit whose user calibration bank was never written,
CONFigure:ADC:LOADcalanswers-200,"Execution error"— andLoadAdcCalibration()returns normally.A control run sending the identical raw primitive produced the same
-200byte for byte, so the firmware is behaving as designed and Core's delegation is faithful. What was missing was any way for the caller to find out: "load the user calibration bank" was a silent no-op that reported success.Design decisions
Throwing, not a result object. A result a caller can ignore reintroduces the original bug in a subtler form.
DeviceCommandFailedExceptioncarriesCommand,ErrorCodeandDeviceResponse, so a caller who wants to branch on the refusal can catch and read the device's own code.A null
ErrorCodemeans "unknown", not "fine". If no readable verdict comes back the command may or may not have been applied. That is not a success, so it also throws — but with a null code, because a refusal will be refused again while an unanswered query usually means the link needs attention first.The queue is drained before the command is sent. The device's error queue is FIFO and can already hold entries from earlier commands or the connect sequence, so a single
SYSTem:ERRor?afterwards could pop somebody else's failure. This is the same trap already documented on the SD listing's terminator, which is why that one is read as a liveness marker and never classified. Draining first is what makes the verdict attributable. Side effect, documented on the API: the drain discards what the queue held, so a caller who wants those entries should read them withDrainErrorQueueAsyncfirst.Additive throughout. The
voidcommands are untouched, on the wire and in signature. The confirming ones live on a separate interface rather than as newIStreamingDevicemembers so existing implementers keep compiling — adding them toIStreamingDevicebroke every test fake that implements it, which is a fair preview of what it would do downstream. This follows the capability-slice shape the device already uses (INetworkConfigurable,ISdCardOperations,ILanChipInfoProvider,IDeviceDiagnostics):Confirmation costs text exchanges rather than a single write, so the
voidcommands remain the right choice mid-stream or when a silent no-op is acceptable. Their docs now state the silence explicitly and point at the twin.Tests
SYSTem:ERRor?in one exchange, asserted in order (the drain happening before the exchange is the point, not just that it happens).-200,"Execution error"surfaces asDeviceCommandFailedExceptionwithErrorCode == -200and the device's own line.**ERROR: ...lines, unreadable verdicts (empty response, no queue reply), extra output ahead of a clean verdict.ParamNamepreserved.TryParseSystemErrorReplyCodeinScpiResponseClassifier, including the overflow and wrong-form cases.DeviceNotConnectedExceptionTestsguard theory.Full suite green on net9.0 and net10.0 (2765 passed each). Five
FirmwareUpdateServiceTests.UpdateWifiModuleAsync_*tests fail, but they fail identically on a clean checkout oforigin/mainat 7965b26 — verified in a separate worktree. Pre-existing and unrelated to this change.Not bench-tested: the confirming path has no hardware evidence yet. The behavior it reacts to does — that is what motivated it.
🤖 Generated with Claude Code