feat(device): read the live capability document and merge it into DeviceCapabilities (closes #390) - #404
feat(device): read the live capability document and merge it into DeviceCapabilities (closes #390)#404tylerkron wants to merge 5 commits into
Conversation
…abilities (closes #390) Firmware v3.5.0+ can describe itself via CONFigure:CAPabilities:JSON? and CONFigure:CAPabilities:APIVersion?. Core now reads that document during initialization and merges it onto the board-derived capabilities. - ScpiMessageProducer: GetCapabilitiesApiVersion / GetCapabilitiesJson. - Daqifi.Core.Device.Capabilities: CapabilityDocument + tolerant parser, plus the device's own streaming rate model (the figure desktop #118 needs and the static table cannot express). - DaqifiDevice.ReadCapabilityDocumentAsync(): gated on Supports(DeviceFeature.CapabilityDocument) and on a schema version in 1..2. Called once from InitializeAsync after the board and firmware version are known; failures are absorbed so a device that cannot answer is unaffected. - DeviceMetadata: keeps the document, and UpdateFromProtobuf now rebuilds the board-derived capabilities only when the board changes, re-applying the overlay last. It previously replaced them on every status frame, discarding both the overlay and the channel counts. - ADR 0001 Decision 2 item 4 updated from growth path to built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…icitly Self-review follow-up. Comparing the detected board against the public DeviceType property left a hole: a consumer that assigns DeviceType itself and then receives a matching status message would never get board-derived capabilities built at all. Track the board the current Capabilities were derived from in its own field instead, so "already built for this board" and "the board was assigned but nothing was built" are distinguishable. Also drops the stale "1-1000" range from StartStreaming's doc comment — the usable range is board- and configuration-dependent, and the literal is now actively misleading with MaxSamplingRate populated from the device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bench measurement, not speculation. The naive shape — one text exchange per query, with a completion window sized for a worst case I had not measured — added 3.64 s to connect+initialize on the bench NQ1 (3.16 s -> 6.80 s). Two fixes, both measured: - The completion window was 1500 ms. The document actually arrives in ~25 ms end to end with a longest inter-chunk gap of 8 ms, so 250 ms already has an order of magnitude of headroom. Saves ~1.25 s. - Send both queries in a single exchange. Swapping the protobuf consumer out and back dominates the cost — the reader thread has to time out of its blocking read first — so a second exchange bought nothing but latency. The schema version still gates whether the document is trusted, which is what the gate is for. Saves ~1.0 s. Connect+initialize is now ~4.29 s against a ~3.16 s baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR Summary by QodoRead SCPI capability document and overlay DeviceCapabilities
AI Description
Diagram
High-Level Assessment
Files changed (18)
|
Code Review by Qodo
1.
|
Two valid findings from review, both about trusting a document that nothing vouched for: - The retained document survived a board change. UpdateFromProtobuf rebuilt the board-derived capabilities for the new board and then re-merged the previous device's document over them — wrong flags, channel counts and rate ceiling on a reconnect to a different unit through the same instance. It is now dropped on a board transition. Deriving for the first time is not a transition: there is no previous board, and a document read before the board was known was still read from this device. - The document body's schema_version was never compared against the version the APIVersion? query reported. The firmware emits both from one macro, so a disagreement means the two halves of the exchange did not come from one coherent response — which matters more now that both queries share a single exchange. Mismatch is now rejected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit ff5c3be |
…rlay Qodo round 2. Clearing the retained capability document on any board change also fired on transitions involving DeviceType.Unknown, which an unrecognized part number produces. That made Unknown -> Nq1 destructive: a document read from this very device (Supports() skips the board axis while the board is Unknown, so the read does happen) was discarded, reverting MaxSamplingRate to the stale 1 kHz board-table ceiling. Per ADR 0001, Unknown means "not yet known", not "a different board" — in neither direction does it evidence a swapped device. The document is now dropped only on a transition between two known boards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 5b3fce0 |
Part 2 of #256. Firmware ≥ v3.5.0 can describe itself; Core now asks, and merges the answer onto
the board-derived capability table.
closes #390
Why now
The trigger recorded on the issue: daqifi-desktop #118
wants a streaming ceiling that depends on the enabled channel set, the channel types, the active
interface, and the encoding.
DeviceCapabilities.FromDeviceType()cannot express that, and nonumber of rows in
DeviceFeatureRequirementscan either. Worse, Core was enforcing a wrongnumber:
MaxSamplingRateis hardcoded to1000, and since #336StreamingFrequencythrows aboveit — so a caller asking for 3 kHz got an
ArgumentOutOfRangeExceptionfrom Core for a rate thedevice accepts. That literal is stale on its own terms; firmware v3.5.0 removed the 1 kHz Type-2
muxed scan cap (firmware #528).
Design
SCPI surface.
ScpiMessageProducer.GetCapabilitiesApiVersion/.GetCapabilitiesJson.Parser.
Daqifi.Core.Device.Capabilities—CapabilityDocumentParser.TryParse/TryParseLines/TryParseApiVersionproducing aCapabilityDocument(identity, flatchannels[], streaming block, storage/power/transport flags, raw JSON). Written against thefirmware emitter in
SCPIInterface.cand cross-checked against the real board, not guessed.Tolerant throughout: every optional field is nullable and every parse returns
falserather thanthrowing, because the firmware's schema rules make additive change routine and require clients to
ignore what they do not know.
CapabilityRateModel.TryComputeMaxRateHz(simultaneousCount, totalCount)evaluates the device's ownpublished formula, which is the client-side preview desktop #118 needs.
Two gates. The read is skipped entirely unless
Supports(DeviceFeature.CapabilityDocument)(already in the table at v3.5.0 from #389), and the document is only trusted when the reported
schema version is within
MinimumCapabilityDocumentApiVersion..MaximumCapabilityDocumentApiVersion(1..2). The upper bound is deliberate — firmware bumps that byte only on a breaking change
(field renamed/removed/retyped, semantics changed, layout reshaped), so a higher version is a
document whose fields may no longer mean what this parser assumes. Falling back to the board table
beats handing a consumer a plausible-but-wrong number. This is the concrete answer to the issue's
"bounds were realigned in firmware #548, don't treat the document as stable across versions"
warning.
Merge, never replace.
CapabilityDocument.MergeIntooverlays only what the document states;everything else keeps its
FromDeviceTypevalue. Because every parsed field is nullable, anomitted field can never read as "hardware absent" — which is exactly what preserves the
"
Unknownboard means not-yet-known" ruleSupports()depends on. Two fields are deliberatelynever overlaid:
HasWincWifiModule(the schema carries no chipset facts by design — it publisheswhat a client can do) and
SupportsStreaming(the firmware emits thestreamingblockunconditionally, so its presence is not evidence either way).
UpdateFromProtobufhad to stop replacing — scope item 6 from the issue comment, and theload-bearing change. It ran
Capabilities = FromDeviceType(DeviceType)on every status messagecarrying a part number, which would clobber the overlay mid-session. It was already a latent bug
independent of this issue: a status frame with a part number but no port fields reset the channel
counts to zero. It now rebuilds only when the board the capabilities were derived from actually
changes, and re-applies the document overlay last so the device's own answer outranks both the
board table and the status message's port counts.
That "board the capabilities were derived from" is tracked in its own field rather than compared
against the public
DeviceTypeproperty — otherwise a consumer that assignsDeviceTypeitselfand then receives a matching status message would never get board-derived capabilities built at
all.
The refresh point, and why
InitializeAsync, afterWaitForChannelsPopulatedAsyncand beforeOnDeviceInitializingAsync.DaqifiOutMessagestops at tag 69, andCapabilities.h:18's claim of "tags 70..75" isaspirational, not shipped. This is a genuine SCPI round-trip, and a text exchange pauses the
protobuf consumer, so it needs a quiescent point rather than a hot path.
Supports()fails closed on an unknown firmware version, so any earlier read would skip on everydevice.
OnDeviceInitializingAsync. Derived-class initialization then already sees thedevice's own capabilities.
initializes exactly as before. Cancellation still propagates — that is the caller's request, not
a device fault.
current_max_rate_hzis computed from the channel setenabled at the moment of the read, so it goes stale as soon as that set changes;
ReadCapabilityDocumentAsync()is public for exactly that. (Issue scope item 7 — reconcilingCore's view of the enabled set with protobuf field 22 — is deliberately not in this PR; it is a
separate change to channel-state tracking.)
Connect latency — measured, then fixed
The naive shape cost real time, so I measured it against
origin/mainon the bench rather thanguessing:
origin/mainbaselineThe 1500 ms window was sized for a worst case I had not measured. On the wire the document is
8014 bytes delivered in ~25 ms end to end with a longest inter-chunk gap of 8 ms, so 250 ms
already carries an order of magnitude of headroom. And swapping the protobuf consumer out and back
dominates the cost — the reader thread must time out of its blocking read first — so a second
exchange bought nothing but latency, to avoid transferring a document that takes 25 ms. The schema
version still gates whether the document is trusted, which is what the gate is for.
Bench validation
DAQiFi Nyquist 1, firmware 3.7.2,
/dev/cu.usbmodem1101, over USB CDC. Non-destructive throughout;the only device state touched was the ADC enable mask, restored to its original
0.CONFigure:CAPabilities:APIVersion?→2. The captured document is checked in verbatim as theparser fixture (
CapabilityDocumentSamples.Nyquist1Firmware372, 8010 chars) so the tests runagainst what the hardware actually emits.
The PWM-capable set
{0,3,4,5,6,7}independently matches what we already knew about this boardfrom firmware source — a good sign the parser is reading the schema correctly rather than
plausibly.
Does the document agree with
FromDeviceType?Yes on everything it states, with one deliberate exception:
FromDeviceTypeleaves them0and neverset them; the protobuf status message supplies them today, and the document now supplies them
too (and agrees with the protobuf: 16/0/16).
MaxSamplingRateis the one real disagreement, and it is the point of the exercise. 1000 isthe stale literal; 22000 is the device's absolute ISR ceiling. This is checked in as a regression
test (
MergeInto_RealNyquist1Document_AgreesWithTheBoardTable) so a future document that startscontradicting the table on a hardware flag fails CI.
End to end
StreamingFrequency = 3000— accepted (ceiling is now 22000 Hz). Onorigin/mainthisthrows
ArgumentOutOfRangeException. That is desktop SD ops should not rely solely on internal IsStreaming state #118 unblocked.the expected 3000 × 0.794 = 2382 for this unit's known PLL clock mismatch (firmware #716). The
device really does accept and deliver the rate Core used to refuse.
current_max_rate_hzmoved0(nothing enabled) →5713(channels 0–3 enabled)→ back after restoring the mask. The rate model previewed 11000 Hz for that selection, correctly
higher than the device's 5713 since the model excludes the transport cap.
init, samples flowing, clean stop, exit code 0.
Tests
Full suite green on net9.0 and net10.0: 2006 passed, 0 failed. No new warnings
(
TreatWarningsAsErrorsis on). New coverage:dedicated-converter set, PWM-capable set, streaming block, rate-model constants, flags.
schema_version, retypedfields, unknown channel kinds, channel entries missing
id/kind.-113reply a below-floor device gives.document-states-nothing leaves every board value intact,
Unknownboard keeps itsnot-yet-known flags, non-positive ceiling does not lower the bound.
is skipped; version out of range is not trusted; unparseable document leaves capabilities alone;
the overlay survives a later status message; and
Supports()answers identically before andafter the overlay for every
DeviceFeature(the "no new refusals" requirement).zero simultaneous count would be a division by zero.
survives one involving
Unknown; and a document whose bodyschema_versiondisagrees with theAPIVersion?reply is rejected.Review rounds
Three rounds; converged with no findings in the last one. Three valid bugs found and fixed, all in
the same family — trusting a document nothing vouched for:
ceiling onto a different unit reconnected through the same instance.
schema_versionwas never compared against theAPIVersion?reply. Thismatters more given both queries share one exchange, so an interleaved or stale line would mean
the version vetted was not the version in hand.
Unknown. SinceSupports()skips the board axis while the board isUnknown, the read doesrun on such a device, so an unrecognized part number followed by a recognized one discarded a
perfectly good document and reverted
MaxSamplingRateto the stale 1 kHz ceiling.Unknownmeans "not yet known", not "a different board" (ADR 0001), so it is now treated as such in both
directions.
One suggestion declined with reasoning on the thread: normalizing part numbers with
Trim()inDeviceTypeDetector. Reasonable on its own, but it changes a shared component this PR does nottouch, with blast radius on discovery and registry matching, and it is not load-bearing for the bug
it was suggested against.
Docs
ADR 0001 Decision 2 item 4 moved from growth path to built, with an implementation note
covering the two gates, the merge rule, the
UpdateFromProtobuffix, the refresh-point reasoning,and the bench comparison. Also dropped the stale
1-1000range fromStartStreaming's doc comment— it is now actively misleading with
MaxSamplingRatepopulated from the device.Scope notes
Deliberately not in this PR: issue scope item 7 (reconciling Core's enabled-channel view with
protobuf field 22) and the MCP-layer half of item 8 — both are changes to channel-state tracking
and the agent layer rather than to the reader, and sibling work is in flight nearby.
Not merging — opened for review.