diff --git a/docs/adr/0001-firmware-feature-gating.md b/docs/adr/0001-firmware-feature-gating.md
index dba8d7a9..a06773ac 100644
--- a/docs/adr/0001-firmware-feature-gating.md
+++ b/docs/adr/0001-firmware-feature-gating.md
@@ -2,7 +2,7 @@
- **Status:** Accepted (2026-06-19)
- **Issue:** [#251](https://github.com/daqifi/daqifi-core/issues/251)
-- **Follow-ups:** [#254](https://github.com/daqifi/daqifi-core/issues/254) (floor + `-113` backstop), [#255](https://github.com/daqifi/daqifi-core/issues/255) (dead-code removal), [#256](https://github.com/daqifi/daqifi-core/issues/256) (version table + `Supports()` seam; #327 reader still deferred)
+- **Follow-ups:** [#254](https://github.com/daqifi/daqifi-core/issues/254) (floor + `-113` backstop), [#255](https://github.com/daqifi/daqifi-core/issues/255) (dead-code removal), [#256](https://github.com/daqifi/daqifi-core/issues/256) (version table + `Supports()` seam), [#390](https://github.com/daqifi/daqifi-core/issues/390) (capability-document reader)
- **Supersedes:** —
> **Note on the evidence section.** §"Context — firmware audit" below is a *living*
@@ -170,12 +170,13 @@ forward-looking third:
3. **`DeviceFeature` version table (deferred → built)** — introduce only when we start consuming
a command newer than the floor. That happened with SD-over-WiFi (≥ v3.7.0), so the table now
exists; see the implementation note below.
-4. **#327 capability document (later, non-blocking)** — when a device answers
- `CONFigure:CAPabilities:APIVersion?` ≥ 1, populate `DeviceCapabilities` from the live
- `CONFigure:CAPabilities:JSON?` document. The floor/board logic remains the bootstrap and
- the fallback for anything that predates or omits the query. Note: #327 the *issue* is
- closed, but the capability *commands* shipped in v3.5.0 and are maintained (#548), so this
- is a real growth path — we just don't lean on "#327 the framework."
+4. **Capability document (deferred → built)** — when a device answers
+ `CONFigure:CAPabilities:APIVersion?` with a schema version this parser understands, populate
+ `DeviceCapabilities` from the live `CONFigure:CAPabilities:JSON?` document. The floor/board
+ logic remains the bootstrap and the fallback for anything that predates or omits the query.
+ Built in [#390](https://github.com/daqifi/daqifi-core/issues/390); see the implementation note
+ below. Note: firmware #327 the *issue* is closed, but the capability *commands* shipped in
+ v3.5.0 and are maintained (#548) — we depend on the commands, not on "#327 the framework."
### Evaluate `Supports` lazily — don't cache version-derived flags
@@ -264,7 +265,54 @@ from the firmware audit table above. Both previously hand-rolled gates — the S
transport gate and the SD-storage-query `-113` backstop — now resolve their required version and
board through it; `EnsureSdFileTransferSupportedOnTransport()` retains only the transport
predicate (which feature applies over WiFi vs. USB) and delegates the support question to the
-seam. Part 2 — the `CONFigure:CAPabilities:JSON?` / `:APIVersion?` reader — remains deferred.
+seam. Part 2 — the `CONFigure:CAPabilities:JSON?` / `:APIVersion?` reader — followed in #390; see
+the next note.
+
+### Implementation note (2026-07-29, issue [#390](https://github.com/daqifi/daqifi-core/issues/390))
+
+Decision 2 item 4 is **built**. `DaqifiDevice.ReadCapabilityDocumentAsync()` issues
+`CONFigure:CAPabilities:APIVersion?` and `CONFigure:CAPabilities:JSON?`, and
+[`CapabilityDocumentParser`](../../src/Daqifi.Core/Device/Capabilities/CapabilityDocumentParser.cs)
+turns the reply into a
+[`CapabilityDocument`](../../src/Daqifi.Core/Device/Capabilities/CapabilityDocument.cs) kept on
+`DeviceMetadata.CapabilityDocument`. Four details worth recording:
+
+- **Two gates, not one.** The read is skipped entirely unless
+ `Supports(DeviceFeature.CapabilityDocument)` (already in the table at v3.5.0), and the document
+ is only trusted when the reported schema version falls within
+ `MinimumCapabilityDocumentApiVersion..MaximumCapabilityDocumentApiVersion` (1..2 today). The
+ upper bound is deliberate: firmware bumps that byte *only* on a breaking change, so a higher
+ version is a document whose fields may no longer mean what this parser assumes — and the board
+ table is a better answer than a plausible-but-wrong number. Raise the constant together with the
+ parser when adopting a newer schema. Both queries go out in **one** text exchange: swapping the
+ protobuf consumer out and back costs far more than either reply does, and measured on the bench
+ a second exchange cost ~1 s of connect latency to avoid transferring a document that takes
+ ~25 ms. The version still gates whether the document is *trusted*, which is the point of the
+ gate.
+- **Merge, never replace.** `CapabilityDocument.MergeInto` overlays only the fields the document
+ actually states; everything else keeps its `FromDeviceType` value. Every parsed field is
+ nullable, so "omitted" can never read as "absent" — which is what preserves the `Unknown`-board
+ rule above. `HasWincWifiModule` and `SupportsStreaming` are never overlaid: the schema carries no
+ chipset facts by design, and it emits the `streaming` block unconditionally.
+- **`UpdateFromProtobuf` had to stop replacing.** It rebuilt `Capabilities` from the board table on
+ *every* status message carrying a part number, which would have discarded the overlay mid-session
+ (and was already discarding channel counts). It now rebuilds only when the detected board
+ 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.
+- **The refresh point is the connect sequence, not status processing.** The document is a real SCPI
+ round-trip — it does not ride along in the protobuf status message, and reading it pauses the
+ protobuf consumer — so `InitializeAsync` reads it once after the device reports its board and
+ firmware version (the version gate fails closed, so an earlier read would skip on every device)
+ and before `OnDeviceInitializingAsync`. A failed read is absorbed: the device keeps its
+ board-derived capabilities, so nothing that works today starts failing. Callers needing a fresh
+ `current_max_rate_hz`, which tracks the enabled channel set, call `ReadCapabilityDocumentAsync()`
+ again. Measured cost on the bench NQ1 over USB: connect+initialize goes from ~3.16 s to ~4.29 s.
+
+Bench-validated against the NQ1 on firmware 3.7.2: the document agrees with `FromDeviceType` on
+every flag it states (SD, WiFi, USB) and supplies the channel counts (16 analog in, 0 analog out,
+16 digital). The single disagreement is `MaxSamplingRate` — the table's hardcoded `1000` against
+the device's `22000` — and it is the disagreement this reader exists to resolve: that literal
+predates firmware v3.5.0 removing the Type-2 muxed scan-rate cap (firmware #528).
## Alternatives considered
@@ -305,12 +353,17 @@ their place.**
firmware SCPI table. Public-API removal, separate from the #253 fix.
3. [#256](https://github.com/daqifi/daqifi-core/issues/256) — `DeviceFeature` version table +
lazy `Supports(...)`: **done** (triggered by SD-over-WiFi @ v3.7.0; see the implementation
- note under Decision 2). The `CONFigure:CAPabilities:JSON?` reader (#327 growth path) is
- **still deferred** and tracked separately.
+ note under Decision 2).
+4. [#390](https://github.com/daqifi/daqifi-core/issues/390) — `CONFigure:CAPabilities:JSON?` /
+ `:APIVersion?` reader populating `DeviceCapabilities`: **done** (triggered by
+ daqifi-desktop [#118](https://github.com/daqifi/daqifi-desktop/issues/118), which needs a
+ per-configuration streaming ceiling the static table cannot express; see the implementation
+ note under Decision 2).
## Out of scope
-The full #327 capability-document reader; the version table (until a post-floor command needs
-it); version-selected command emit (obviated by the floor). This ADR covers the
-investigation, the strategy decision, the v3.5.0 floor, and the minimal board-gate + typed
-`-113` backstop.
+The version table (until a post-floor command needs it) and version-selected command emit
+(obviated by the floor) were both out of scope for this ADR's original decision; the table has
+since been built under #256, and the capability-document reader — deferred at the time of writing
+— under #390. This ADR covers the investigation, the strategy decision, the v3.5.0 floor, and the
+minimal board-gate + typed `-113` backstop.
diff --git a/src/Daqifi.Core.Tests/Communication/Producers/ScpiMessageProducerTests.cs b/src/Daqifi.Core.Tests/Communication/Producers/ScpiMessageProducerTests.cs
index 6bdb14d5..4a44fc92 100644
--- a/src/Daqifi.Core.Tests/Communication/Producers/ScpiMessageProducerTests.cs
+++ b/src/Daqifi.Core.Tests/Communication/Producers/ScpiMessageProducerTests.cs
@@ -32,6 +32,28 @@ public void GetDeviceInfo_ReturnsCorrectCommand()
AssertMessageFormat(message);
}
+ [Fact]
+ public void GetCapabilitiesApiVersion_ReturnsCorrectCommand()
+ {
+ // Act
+ var message = ScpiMessageProducer.GetCapabilitiesApiVersion;
+
+ // Assert
+ Assert.Equal("CONFigure:CAPabilities:APIVersion?", message.Data);
+ AssertMessageFormat(message);
+ }
+
+ [Fact]
+ public void GetCapabilitiesJson_ReturnsCorrectCommand()
+ {
+ // Act
+ var message = ScpiMessageProducer.GetCapabilitiesJson;
+
+ // Assert
+ Assert.Equal("CONFigure:CAPabilities:JSON?", message.Data);
+ AssertMessageFormat(message);
+ }
+
[Fact]
public void DisableDeviceEcho_ReturnsCorrectCommand()
{
diff --git a/src/Daqifi.Core.Tests/Device/Capabilities/CapabilityDocumentMergeTests.cs b/src/Daqifi.Core.Tests/Device/Capabilities/CapabilityDocumentMergeTests.cs
new file mode 100644
index 00000000..6f323a7f
--- /dev/null
+++ b/src/Daqifi.Core.Tests/Device/Capabilities/CapabilityDocumentMergeTests.cs
@@ -0,0 +1,157 @@
+using System;
+using Daqifi.Core.Device;
+using Daqifi.Core.Device.Capabilities;
+
+namespace Daqifi.Core.Tests.Device.Capabilities;
+
+///
+/// Tests that a capability document overlays board-derived
+/// rather than replacing them (ADR 0001, Decision 2 item 4).
+///
+public class CapabilityDocumentMergeTests
+{
+ private static CapabilityDocument BenchDocument()
+ {
+ Assert.True(CapabilityDocumentParser.TryParse(
+ CapabilityDocumentSamples.Nyquist1Firmware372, out var document));
+ return document!;
+ }
+
+ private static CapabilityDocument Parse(string json)
+ {
+ Assert.True(CapabilityDocumentParser.TryParse(json, out var document));
+ return document!;
+ }
+
+ [Fact]
+ public void MergeInto_RealNyquist1Document_AgreesWithTheBoardTable()
+ {
+ // The bench check the issue asks for, pinned as a regression test: for a board the static
+ // table already describes, the device's own answer must not contradict it.
+ var boardDerived = DeviceCapabilities.FromDeviceType(DeviceType.Nyquist1);
+ var merged = DeviceCapabilities.FromDeviceType(DeviceType.Nyquist1);
+
+ BenchDocument().MergeInto(merged);
+
+ Assert.Equal(boardDerived.HasSdCard, merged.HasSdCard);
+ Assert.Equal(boardDerived.HasWiFi, merged.HasWiFi);
+ Assert.Equal(boardDerived.HasUsb, merged.HasUsb);
+ Assert.Equal(boardDerived.SupportsStreaming, merged.SupportsStreaming);
+ }
+
+ [Fact]
+ public void MergeInto_RealNyquist1Document_FillsChannelCountsAndRaisesTheRateCeiling()
+ {
+ var capabilities = DeviceCapabilities.FromDeviceType(DeviceType.Nyquist1);
+
+ BenchDocument().MergeInto(capabilities);
+
+ Assert.Equal(16, capabilities.AnalogInputChannels);
+ Assert.Equal(0, capabilities.AnalogOutputChannels);
+ Assert.Equal(16, capabilities.DigitalChannels);
+
+ // The one field where the device disagrees with the table, and the reason this reader
+ // exists: the hardcoded 1000 Hz predates firmware v3.5.0 removing the muxed scan-rate cap.
+ Assert.Equal(22000, capabilities.MaxSamplingRate);
+ }
+
+ [Fact]
+ public void MergeInto_LeavesFieldsTheSchemaDoesNotCarry()
+ {
+ // The schema publishes what a client can do, not what parts are fitted, so it has no
+ // WINC-module fact and no "streaming supported" boolean. Both stay board-derived.
+ var capabilities = DeviceCapabilities.FromDeviceType(DeviceType.Nyquist1);
+
+ BenchDocument().MergeInto(capabilities);
+
+ Assert.True(capabilities.HasWincWifiModule);
+ Assert.True(capabilities.SupportsStreaming);
+ }
+
+ [Fact]
+ public void MergeInto_DocumentThatStatesNothing_LeavesEveryBoardValueIntact()
+ {
+ var capabilities = DeviceCapabilities.FromDeviceType(DeviceType.Nyquist3);
+ capabilities.AnalogInputChannels = 8;
+ capabilities.AnalogOutputChannels = 2;
+ capabilities.DigitalChannels = 16;
+
+ Parse("{\"schema_version\":2}").MergeInto(capabilities);
+
+ Assert.True(capabilities.HasSdCard);
+ Assert.True(capabilities.HasWiFi);
+ Assert.True(capabilities.HasUsb);
+ Assert.True(capabilities.HasWincWifiModule);
+ Assert.Equal(8, capabilities.AnalogInputChannels);
+ Assert.Equal(2, capabilities.AnalogOutputChannels);
+ Assert.Equal(16, capabilities.DigitalChannels);
+ Assert.Equal(1000, capabilities.MaxSamplingRate);
+ }
+
+ [Fact]
+ public void MergeInto_PartialDocument_OverlaysOnlyWhatItStates()
+ {
+ var capabilities = DeviceCapabilities.FromDeviceType(DeviceType.Nyquist1);
+ capabilities.AnalogInputChannels = 16;
+
+ // States storage only. Everything else — transports, channels, streaming — must fall back.
+ Parse("{\"schema_version\":2,\"storage\":{\"sd_supported\":false}}").MergeInto(capabilities);
+
+ Assert.False(capabilities.HasSdCard);
+ Assert.True(capabilities.HasWiFi);
+ Assert.True(capabilities.HasUsb);
+ Assert.Equal(16, capabilities.AnalogInputChannels);
+ Assert.Equal(1000, capabilities.MaxSamplingRate);
+ }
+
+ [Fact]
+ public void MergeInto_ChannelArrayIsOverlaidAsASet()
+ {
+ // channels[] is the board's complete channel list, so once it is present a count of zero
+ // for a kind is a real answer rather than a gap to fall back on.
+ var capabilities = DeviceCapabilities.FromDeviceType(DeviceType.Nyquist1);
+ capabilities.AnalogInputChannels = 99;
+ capabilities.AnalogOutputChannels = 99;
+ capabilities.DigitalChannels = 99;
+
+ Parse("{\"schema_version\":2,\"channels\":[{\"id\":0,\"kind\":\"analog-input\"}]}")
+ .MergeInto(capabilities);
+
+ Assert.Equal(1, capabilities.AnalogInputChannels);
+ Assert.Equal(0, capabilities.AnalogOutputChannels);
+ Assert.Equal(0, capabilities.DigitalChannels);
+ }
+
+ [Fact]
+ public void MergeInto_NonPositiveMaximumSampleRate_DoesNotLowerTheCeiling()
+ {
+ // A 0 or negative ceiling would make every streaming frequency invalid; keep the board's.
+ var capabilities = DeviceCapabilities.FromDeviceType(DeviceType.Nyquist1);
+
+ Parse("{\"schema_version\":2,\"streaming\":{\"sample_rate_range_hz\":{\"min\":1,\"max\":0}}}")
+ .MergeInto(capabilities);
+
+ Assert.Equal(1000, capabilities.MaxSamplingRate);
+ }
+
+ [Fact]
+ public void MergeInto_UnknownBoard_DoesNotTurnHardwareFlagsOffThatTheDocumentOmits()
+ {
+ // Preserves the ADR 0001 rule that all-false capabilities on an Unknown board mean
+ // "not yet known", not "hardware absent": an omitted field never flips a flag.
+ var capabilities = DeviceCapabilities.FromDeviceType(DeviceType.Unknown);
+
+ Parse("{\"schema_version\":2,\"transports\":{\"usb\":{\"supported\":true}}}")
+ .MergeInto(capabilities);
+
+ Assert.True(capabilities.HasUsb);
+ Assert.False(capabilities.HasSdCard);
+ Assert.False(capabilities.HasWiFi);
+ }
+
+ [Fact]
+ public void MergeInto_NullCapabilities_Throws()
+ {
+ Assert.Throws(() => BenchDocument().MergeInto(null!));
+ }
+}
diff --git a/src/Daqifi.Core.Tests/Device/Capabilities/CapabilityDocumentParserTests.cs b/src/Daqifi.Core.Tests/Device/Capabilities/CapabilityDocumentParserTests.cs
new file mode 100644
index 00000000..3136580e
--- /dev/null
+++ b/src/Daqifi.Core.Tests/Device/Capabilities/CapabilityDocumentParserTests.cs
@@ -0,0 +1,309 @@
+using System.Linq;
+using Daqifi.Core.Device.Capabilities;
+
+namespace Daqifi.Core.Tests.Device.Capabilities;
+
+///
+/// Parser tests for CONFigure:CAPabilities:JSON? and
+/// CONFigure:CAPabilities:APIVersion?, driven by a document captured from real hardware
+/// ().
+///
+public class CapabilityDocumentParserTests
+{
+ private static CapabilityDocument ParseBenchDocument()
+ {
+ Assert.True(CapabilityDocumentParser.TryParse(
+ CapabilityDocumentSamples.Nyquist1Firmware372, out var document));
+ return document!;
+ }
+
+ [Fact]
+ public void TryParse_RealNyquist1Document_ReadsSchemaAndIdentity()
+ {
+ var document = ParseBenchDocument();
+
+ Assert.Equal(2, document.SchemaVersion);
+ Assert.Equal("https://daqifi.com/schemas/capability/v1", document.SchemaUri);
+ Assert.NotNull(document.Identity);
+ Assert.Equal("DAQiFi", document.Identity!.Vendor);
+ Assert.Equal("Nyquist", document.Identity.Model);
+ Assert.Equal("NQ1", document.Identity.Variant);
+ Assert.Equal("7E2815916200E898", document.Identity.Serial);
+ Assert.Equal("3.7.2", document.Identity.FirmwareRevision);
+ Assert.Equal("2.0.0", document.Identity.HardwareRevision);
+ }
+
+ [Fact]
+ public void TryParse_RealNyquist1Document_ReadsChannelCountsByKind()
+ {
+ var document = ParseBenchDocument();
+
+ Assert.Equal(32, document.Channels.Count);
+ Assert.Equal(16, document.CountChannels(CapabilityChannelKind.AnalogInput));
+ Assert.Equal(0, document.CountChannels(CapabilityChannelKind.AnalogOutput));
+ Assert.Equal(16, document.CountChannels(CapabilityChannelKind.DigitalIo));
+ }
+
+ [Fact]
+ public void TryParse_RealNyquist1Document_ReadsAnalogInputDetail()
+ {
+ var document = ParseBenchDocument();
+
+ var channel = document.Channels.Single(
+ c => c.Kind == CapabilityChannelKind.AnalogInput && c.Id == 0);
+
+ Assert.Equal("analog-input", channel.RawKind);
+ Assert.Equal("voltage", channel.SignalType);
+ Assert.Equal("V", channel.Unit);
+ Assert.Equal(12, channel.ResolutionBits);
+ Assert.False(channel.IsDifferential);
+ Assert.Equal(0.0, channel.RangeMinimum);
+ Assert.Equal(5.0, channel.RangeMaximum);
+ Assert.Equal(1.0, channel.CalibrationSlope);
+ Assert.Equal(0.0, channel.CalibrationIntercept);
+ Assert.False(channel.SupportsPwm);
+ }
+
+ [Fact]
+ public void TryParse_RealNyquist1Document_IdentifiesDedicatedConverterChannels()
+ {
+ // The NQ1's Type-1 (dedicated-ADC, zero-skew) analog inputs. This is the count that
+ // divides type1_aggregate_max_hz in the device's rate model.
+ var document = ParseBenchDocument();
+
+ var simultaneous = document.Channels
+ .Where(c => c.Kind == CapabilityChannelKind.AnalogInput && c.IsSimultaneous)
+ .Select(c => c.Id)
+ .OrderBy(id => id)
+ .ToArray();
+
+ Assert.Equal(new[] { 4, 8, 10, 12, 14 }, simultaneous);
+ }
+
+ [Fact]
+ public void TryParse_RealNyquist1Document_IdentifiesPwmCapableDigitalPins()
+ {
+ var document = ParseBenchDocument();
+
+ var pwmCapable = document.Channels
+ .Where(c => c.Kind == CapabilityChannelKind.DigitalIo && c.SupportsPwm)
+ .Select(c => c.Id)
+ .OrderBy(id => id)
+ .ToArray();
+
+ Assert.Equal(new[] { 0, 3, 4, 5, 6, 7 }, pwmCapable);
+
+ var pwmPin = document.Channels.Single(
+ c => c.Kind == CapabilityChannelKind.DigitalIo && c.Id == 3);
+ Assert.Equal(1, pwmPin.PwmMinimumFrequencyHz);
+ Assert.Equal(50000, pwmPin.PwmMaximumFrequencyHz);
+
+ // Absence of the "pwm" key is the negative answer — not a false-valued flag.
+ var plainPin = document.Channels.Single(
+ c => c.Kind == CapabilityChannelKind.DigitalIo && c.Id == 1);
+ Assert.False(plainPin.SupportsPwm);
+ Assert.Null(plainPin.PwmMinimumFrequencyHz);
+ }
+
+ [Fact]
+ public void TryParse_RealNyquist1Document_ReadsStreamingBlock()
+ {
+ var document = ParseBenchDocument();
+
+ Assert.NotNull(document.Streaming);
+ var streaming = document.Streaming!;
+ Assert.Equal(1, streaming.MinimumSampleRateHz);
+ Assert.Equal(22000, streaming.MaximumSampleRateHz);
+ Assert.Equal(500, streaming.ConservativeEnvelopeHz);
+ // Captured with no channels enabled: the firmware reports 0, and 0 is a real answer that
+ // must survive parsing rather than collapse to "not stated".
+ Assert.Equal(0, streaming.CurrentMaximumRateHz);
+ Assert.Equal("error", streaming.RateValidation);
+ Assert.Equal(new[] { "pb", "csv", "json" }, streaming.Encodings);
+ Assert.Equal(new[] { "usb", "wifi", "sd" }, streaming.Transports);
+ }
+
+ [Fact]
+ public void TryParse_RealNyquist1Document_ReadsRateModelConstants()
+ {
+ var document = ParseBenchDocument();
+
+ var model = document.Streaming!.RateModel;
+ Assert.NotNull(model);
+ Assert.Equal(22000, model!.AbsoluteMaximumHz);
+ Assert.Equal(55000, model.Type1AggregateMaximumHz);
+ Assert.Equal(110000, model.PerTickBudgetHz);
+ Assert.Equal(6, model.PerTickOverhead);
+ Assert.Contains("absolute_max_hz", model.Formula);
+ }
+
+ [Fact]
+ public void TryParse_RealNyquist1Document_ReadsStorageTransportAndPowerFlags()
+ {
+ var document = ParseBenchDocument();
+
+ Assert.True(document.SdSupported);
+ Assert.True(document.UsbSupported);
+ Assert.True(document.WifiSupported);
+ Assert.False(document.EthernetSupported);
+ Assert.True(document.BatteryPresent);
+ Assert.True(document.ExternalPowerSupported);
+ }
+
+ [Fact]
+ public void TryParse_RealNyquist1Document_RetainsRawJson()
+ {
+ var document = ParseBenchDocument();
+
+ Assert.Equal(CapabilityDocumentSamples.Nyquist1Firmware372, document.RawJson);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("not json at all")]
+ [InlineData("{\"schema_version\":2")] // truncated mid-document
+ [InlineData("[1,2,3]")] // valid JSON, wrong shape
+ [InlineData("{\"identity\":{\"variant\":\"NQ1\"}}")] // no schema_version
+ [InlineData("{\"schema_version\":\"two\"}")] // schema_version is not a number
+ public void TryParse_UnusableInput_ReturnsFalse(string? json)
+ {
+ Assert.False(CapabilityDocumentParser.TryParse(json, out var document));
+ Assert.Null(document);
+ }
+
+ [Fact]
+ public void TryParse_DocumentWithOnlySchemaVersion_ParsesWithEverythingUnstated()
+ {
+ // A document stripped to its one required field still parses; every optional block reads
+ // as "not stated" so the merge leaves board-derived values alone.
+ Assert.True(CapabilityDocumentParser.TryParse("{\"schema_version\":2}", out var document));
+
+ Assert.Equal(2, document!.SchemaVersion);
+ Assert.Null(document.Identity);
+ Assert.Empty(document.Channels);
+ Assert.Null(document.Streaming);
+ Assert.Null(document.SdSupported);
+ Assert.Null(document.UsbSupported);
+ Assert.Null(document.WifiSupported);
+ }
+
+ [Fact]
+ public void TryParse_FieldsWithUnexpectedTypes_ReadAsUnstated()
+ {
+ // Forward-compatibility: a retyped field must degrade to "not stated" rather than throw,
+ // so the rest of the document still contributes.
+ const string json = """
+ {"schema_version":2,
+ "storage":{"sd_supported":"yes"},
+ "transports":{"usb":{"supported":1}},
+ "streaming":{"sample_rate_range_hz":{"max":"fast"},"conservative_envelope_hz":500}}
+ """;
+
+ Assert.True(CapabilityDocumentParser.TryParse(json, out var document));
+
+ Assert.Null(document!.SdSupported);
+ Assert.Null(document.UsbSupported);
+ Assert.Null(document.Streaming!.MaximumSampleRateHz);
+ Assert.Equal(500, document.Streaming.ConservativeEnvelopeHz);
+ }
+
+ [Fact]
+ public void TryParse_UnknownChannelKind_IsRetainedAsUnknown()
+ {
+ // Adding a channel kind is an additive change that does not bump the schema version, so an
+ // unrecognized kind must not fail the parse or be miscounted as a known one.
+ const string json = """
+ {"schema_version":2,"channels":[
+ {"id":0,"kind":"analog-input"},
+ {"id":0,"kind":"counter"}]}
+ """;
+
+ Assert.True(CapabilityDocumentParser.TryParse(json, out var document));
+
+ Assert.Equal(2, document!.Channels.Count);
+ Assert.Equal(1, document.CountChannels(CapabilityChannelKind.AnalogInput));
+ Assert.Equal(0, document.CountChannels(CapabilityChannelKind.AnalogOutput));
+ Assert.Equal(0, document.CountChannels(CapabilityChannelKind.DigitalIo));
+
+ var unknown = document.Channels[1];
+ Assert.Equal(CapabilityChannelKind.Unknown, unknown.Kind);
+ Assert.Equal("counter", unknown.RawKind);
+ }
+
+ [Fact]
+ public void TryParse_ChannelEntryMissingIdOrKind_IsSkipped()
+ {
+ const string json = """
+ {"schema_version":2,"channels":[
+ {"kind":"analog-input"},
+ {"id":1},
+ "not-an-object",
+ {"id":2,"kind":"analog-input"}]}
+ """;
+
+ Assert.True(CapabilityDocumentParser.TryParse(json, out var document));
+
+ var channel = Assert.Single(document!.Channels);
+ Assert.Equal(2, channel.Id);
+ }
+
+ [Fact]
+ public void TryParseLines_SkipsEchoAndPromptAndFindsDocument()
+ {
+ string[] lines =
+ [
+ "CONFigure:CAPabilities:JSON?",
+ CapabilityDocumentSamples.Nyquist1Firmware372,
+ "DAQIFI>"
+ ];
+
+ Assert.True(CapabilityDocumentParser.TryParseLines(lines, out var document));
+ Assert.Equal("NQ1", document!.Identity!.Variant);
+ }
+
+ [Fact]
+ public void TryParseLines_WithoutADocumentLine_ReturnsFalse()
+ {
+ string[] lines = ["CONFigure:CAPabilities:JSON?", "**ERROR: -113, \"Undefined header\"", "DAQIFI>"];
+
+ Assert.False(CapabilityDocumentParser.TryParseLines(lines, out var document));
+ Assert.Null(document);
+ }
+
+ [Fact]
+ public void TryParseLines_UnparseableJsonLineDoesNotMaskALaterValidOne()
+ {
+ string[] lines = ["{\"unrelated\":true}", CapabilityDocumentSamples.Nyquist1Firmware372];
+
+ Assert.True(CapabilityDocumentParser.TryParseLines(lines, out var document));
+ Assert.Equal(2, document!.SchemaVersion);
+ }
+
+ [Fact]
+ public void TryParseApiVersion_ReadsTheBareIntegerReply()
+ {
+ string[] lines = ["CONFigure:CAPabilities:APIVersion?", "2", "DAQIFI>"];
+
+ Assert.True(CapabilityDocumentParser.TryParseApiVersion(lines, out var apiVersion));
+ Assert.Equal(2, apiVersion);
+ }
+
+ [Fact]
+ public void TryParseApiVersion_OnUndefinedHeaderError_ReturnsFalse()
+ {
+ // What a below-floor device replies: the query does not exist, so there is no version.
+ string[] lines = ["**ERROR: -113, \"Undefined header\""];
+
+ Assert.False(CapabilityDocumentParser.TryParseApiVersion(lines, out var apiVersion));
+ Assert.Equal(0, apiVersion);
+ }
+
+ [Fact]
+ public void TryParseApiVersion_WithNoLines_ReturnsFalse()
+ {
+ Assert.False(CapabilityDocumentParser.TryParseApiVersion([], out var apiVersion));
+ Assert.Equal(0, apiVersion);
+ }
+}
diff --git a/src/Daqifi.Core.Tests/Device/Capabilities/CapabilityDocumentSamples.cs b/src/Daqifi.Core.Tests/Device/Capabilities/CapabilityDocumentSamples.cs
new file mode 100644
index 00000000..6622fd02
--- /dev/null
+++ b/src/Daqifi.Core.Tests/Device/Capabilities/CapabilityDocumentSamples.cs
@@ -0,0 +1,24 @@
+namespace Daqifi.Core.Tests.Device.Capabilities;
+
+///
+/// Capability documents captured from real hardware, used as parser fixtures.
+///
+internal static class CapabilityDocumentSamples
+{
+ ///
+ /// The verbatim response of CONFigure:CAPabilities:JSON? from the bench DAQiFi Nyquist 1
+ /// (serial 7E2815916200E898, hardware rev 2.0.0) running firmware 3.7.2, captured over
+ /// USB CDC on 2026-07-29. No channels were enabled at the time of capture, which is why
+ /// current_max_rate_hz reads 0 — the firmware reports 0 rather than a rate when there is
+ /// nothing to stream.
+ ///
+ ///
+ /// Kept verbatim on purpose, including the extensions escape hatches and the blocks
+ /// daqifi-core does not read: the parser's contract is that it ignores what it does not know,
+ /// and trimming the fixture to the fields under test would stop exercising that.
+ ///
+ public const string Nyquist1Firmware372 =
+ """
+ {"schema_version":2,"schema_uri":"https://daqifi.com/schemas/capability/v1","extensions":{},"identity":{"vendor":"DAQiFi","model":"Nyquist","variant":"NQ1","serial":"7E2815916200E898","firmware_rev":"3.7.2","hardware_rev":"2.0.0","usb":{"vid":1240,"pid":63380,"class":"CDC"}},"channels":[{"id":0,"kind":"analog-input","signal_type":"voltage","unit":"V","resolution_bits":12,"simultaneous":false,"differential":false,"ranges":[{"min":0.000,"max":5.000}],"calibration":{"model":"linear","user_override_supported":true,"slope":1.000000,"intercept":0.000000},"extensions":{}},{"id":1,"kind":"analog-input","signal_type":"voltage","unit":"V","resolution_bits":12,"simultaneous":false,"differential":false,"ranges":[{"min":0.000,"max":5.000}],"calibration":{"model":"linear","user_override_supported":true,"slope":1.000000,"intercept":0.000000},"extensions":{}},{"id":2,"kind":"analog-input","signal_type":"voltage","unit":"V","resolution_bits":12,"simultaneous":false,"differential":false,"ranges":[{"min":0.000,"max":5.000}],"calibration":{"model":"linear","user_override_supported":true,"slope":1.000000,"intercept":0.000000},"extensions":{}},{"id":3,"kind":"analog-input","signal_type":"voltage","unit":"V","resolution_bits":12,"simultaneous":false,"differential":false,"ranges":[{"min":0.000,"max":5.000}],"calibration":{"model":"linear","user_override_supported":true,"slope":1.000000,"intercept":0.000000},"extensions":{}},{"id":4,"kind":"analog-input","signal_type":"voltage","unit":"V","resolution_bits":12,"simultaneous":true,"differential":false,"ranges":[{"min":0.000,"max":5.000}],"calibration":{"model":"linear","user_override_supported":true,"slope":1.000000,"intercept":0.000000},"extensions":{}},{"id":5,"kind":"analog-input","signal_type":"voltage","unit":"V","resolution_bits":12,"simultaneous":false,"differential":false,"ranges":[{"min":0.000,"max":5.000}],"calibration":{"model":"linear","user_override_supported":true,"slope":1.000000,"intercept":0.000000},"extensions":{}},{"id":6,"kind":"analog-input","signal_type":"voltage","unit":"V","resolution_bits":12,"simultaneous":false,"differential":false,"ranges":[{"min":0.000,"max":5.000}],"calibration":{"model":"linear","user_override_supported":true,"slope":1.000000,"intercept":0.000000},"extensions":{}},{"id":7,"kind":"analog-input","signal_type":"voltage","unit":"V","resolution_bits":12,"simultaneous":false,"differential":false,"ranges":[{"min":0.000,"max":5.000}],"calibration":{"model":"linear","user_override_supported":true,"slope":1.000000,"intercept":0.000000},"extensions":{}},{"id":8,"kind":"analog-input","signal_type":"voltage","unit":"V","resolution_bits":12,"simultaneous":true,"differential":false,"ranges":[{"min":0.000,"max":5.000}],"calibration":{"model":"linear","user_override_supported":true,"slope":1.000000,"intercept":0.000000},"extensions":{}},{"id":9,"kind":"analog-input","signal_type":"voltage","unit":"V","resolution_bits":12,"simultaneous":false,"differential":false,"ranges":[{"min":0.000,"max":5.000}],"calibration":{"model":"linear","user_override_supported":true,"slope":1.000000,"intercept":0.000000},"extensions":{}},{"id":10,"kind":"analog-input","signal_type":"voltage","unit":"V","resolution_bits":12,"simultaneous":true,"differential":false,"ranges":[{"min":0.000,"max":5.000}],"calibration":{"model":"linear","user_override_supported":true,"slope":1.000000,"intercept":0.000000},"extensions":{}},{"id":11,"kind":"analog-input","signal_type":"voltage","unit":"V","resolution_bits":12,"simultaneous":false,"differential":false,"ranges":[{"min":0.000,"max":5.000}],"calibration":{"model":"linear","user_override_supported":true,"slope":1.000000,"intercept":0.000000},"extensions":{}},{"id":12,"kind":"analog-input","signal_type":"voltage","unit":"V","resolution_bits":12,"simultaneous":true,"differential":false,"ranges":[{"min":0.000,"max":5.000}],"calibration":{"model":"linear","user_override_supported":true,"slope":1.000000,"intercept":0.000000},"extensions":{}},{"id":13,"kind":"analog-input","signal_type":"voltage","unit":"V","resolution_bits":12,"simultaneous":false,"differential":false,"ranges":[{"min":0.000,"max":5.000}],"calibration":{"model":"linear","user_override_supported":true,"slope":1.000000,"intercept":0.000000},"extensions":{}},{"id":14,"kind":"analog-input","signal_type":"voltage","unit":"V","resolution_bits":12,"simultaneous":true,"differential":false,"ranges":[{"min":0.000,"max":5.000}],"calibration":{"model":"linear","user_override_supported":true,"slope":1.000000,"intercept":0.000000},"extensions":{}},{"id":15,"kind":"analog-input","signal_type":"voltage","unit":"V","resolution_bits":12,"simultaneous":false,"differential":false,"ranges":[{"min":0.000,"max":5.000}],"calibration":{"model":"linear","user_override_supported":true,"slope":1.000000,"intercept":0.000000},"extensions":{}},{"id":0,"kind":"digital-io","features":{"input":true,"output":true,"pwm":{"min_freq_hz":1,"max_freq_hz":50000,"resolution_bits":16}},"extensions":{}},{"id":1,"kind":"digital-io","features":{"input":true,"output":true},"extensions":{}},{"id":2,"kind":"digital-io","features":{"input":true,"output":true},"extensions":{}},{"id":3,"kind":"digital-io","features":{"input":true,"output":true,"pwm":{"min_freq_hz":1,"max_freq_hz":50000,"resolution_bits":16}},"extensions":{}},{"id":4,"kind":"digital-io","features":{"input":true,"output":true,"pwm":{"min_freq_hz":1,"max_freq_hz":50000,"resolution_bits":16}},"extensions":{}},{"id":5,"kind":"digital-io","features":{"input":true,"output":true,"pwm":{"min_freq_hz":1,"max_freq_hz":50000,"resolution_bits":16}},"extensions":{}},{"id":6,"kind":"digital-io","features":{"input":true,"output":true,"pwm":{"min_freq_hz":1,"max_freq_hz":50000,"resolution_bits":16}},"extensions":{}},{"id":7,"kind":"digital-io","features":{"input":true,"output":true,"pwm":{"min_freq_hz":1,"max_freq_hz":50000,"resolution_bits":16}},"extensions":{}},{"id":8,"kind":"digital-io","features":{"input":true,"output":true},"extensions":{}},{"id":9,"kind":"digital-io","features":{"input":true,"output":true},"extensions":{}},{"id":10,"kind":"digital-io","features":{"input":true,"output":true},"extensions":{}},{"id":11,"kind":"digital-io","features":{"input":true,"output":true},"extensions":{}},{"id":12,"kind":"digital-io","features":{"input":true,"output":true},"extensions":{}},{"id":13,"kind":"digital-io","features":{"input":true,"output":true},"extensions":{}},{"id":14,"kind":"digital-io","features":{"input":true,"output":true},"extensions":{}},{"id":15,"kind":"digital-io","features":{"input":true,"output":true},"extensions":{}}],"streaming":{"encodings":["pb","csv","json"],"transports":["usb","wifi","sd"],"sample_rate_range_hz":{"min":1,"max":22000},"conservative_envelope_hz":500,"current_max_rate_hz":0,"rate_model":{"formula":"min(absolute_max_hz, type1_aggregate_max_hz/simultaneous_count, per_tick_budget_hz/(per_tick_overhead+total_count))","absolute_max_hz":22000,"type1_aggregate_max_hz":55000,"per_tick_budget_hz":110000,"per_tick_overhead":6},"rate_validation":"error","buffer_ranges_bytes":{"usb":{"min":4096,"max":65536,"default":16384},"wifi":{"min":1400,"max":65536,"default":14000},"sd":{"min":4096,"max":65536,"default":32768},"encoder":{"min":1024,"max":65536,"default":8192},"sample_pool":{"min":100,"max":10000,"default":1100}},"test_patterns":[0,1,2,3,4,5,6],"extensions":{}},"storage":{"sd_supported":true,"filesystems":["FAT32"],"max_file_size_bytes":4190109695,"extensions":{}},"power":{"sources":["usb","external","battery"],"battery_present":true,"external_power_supported":true,"otg_output_supported":true,"extensions":{}},"transports":{"usb":{"supported":true,"extensions":{}},"wifi":{"supported":true,"bands":["2.4GHz"],"modes":["sta","ap"],"security":["open","wpa","wpa2"],"tcp_command_port":9760,"udp_announce_port":30303,"extensions":{}},"ethernet":{"supported":false,"extensions":{}},"serial_debug":{"supported":true,"baud":921600,"extensions":{}}},"triggers":{"hardware_inputs":[],"software":true,"extensions":{}}}
+ """;
+}
diff --git a/src/Daqifi.Core.Tests/Device/Capabilities/CapabilityRateModelTests.cs b/src/Daqifi.Core.Tests/Device/Capabilities/CapabilityRateModelTests.cs
new file mode 100644
index 00000000..41ff1efe
--- /dev/null
+++ b/src/Daqifi.Core.Tests/Device/Capabilities/CapabilityRateModelTests.cs
@@ -0,0 +1,117 @@
+using System;
+using System.Linq;
+using Daqifi.Core.Device.Capabilities;
+
+namespace Daqifi.Core.Tests.Device.Capabilities;
+
+///
+/// Tests for the client-side evaluation of the device's published streaming-rate formula.
+///
+public class CapabilityRateModelTests
+{
+ /// The bench NQ1's constants, as captured on firmware 3.7.2.
+ private static CapabilityRateModel BenchModel()
+ {
+ Assert.True(CapabilityDocumentParser.TryParse(
+ CapabilityDocumentSamples.Nyquist1Firmware372, out var document));
+ return document!.Streaming!.RateModel!;
+ }
+
+ [Theory]
+ // min(22000, -, 110000/(6+4)) — muxed-only selection, budget term binds.
+ [InlineData(0, 4, 11000)]
+ // min(22000, 55000/1, 110000/(6+1)) — one dedicated channel; budget still binds.
+ [InlineData(1, 1, 15714)]
+ // min(22000, 55000/5, 110000/(6+5)) — five dedicated channels; Type-1 aggregate binds.
+ [InlineData(5, 5, 10000)]
+ // min(22000, 55000/5, 110000/(6+16)) — the whole board; budget binds hardest.
+ [InlineData(5, 16, 5000)]
+ // Nothing selected: only the absolute ceiling and the overhead-only budget term apply.
+ [InlineData(0, 0, 18333)]
+ public void TryComputeMaxRateHz_EvaluatesTheDeviceFormula(
+ int simultaneousCount, int totalCount, int expected)
+ {
+ Assert.True(BenchModel().TryComputeMaxRateHz(simultaneousCount, totalCount, out var maxRateHz));
+ Assert.Equal(expected, maxRateHz);
+ }
+
+ [Fact]
+ public void TryComputeMaxRateHz_NeverExceedsTheAbsoluteCeiling()
+ {
+ var model = new CapabilityRateModel
+ {
+ AbsoluteMaximumHz = 22000,
+ Type1AggregateMaximumHz = 55000,
+ PerTickBudgetHz = 110000,
+ PerTickOverhead = 0
+ };
+
+ Assert.True(model.TryComputeMaxRateHz(0, 1, out var maxRateHz));
+ Assert.Equal(22000, maxRateHz);
+ }
+
+ [Fact]
+ public void TryComputeMaxRateHz_WithNoConstants_ReturnsFalse()
+ {
+ Assert.False(new CapabilityRateModel().TryComputeMaxRateHz(2, 4, out var maxRateHz));
+ Assert.Equal(0, maxRateHz);
+ }
+
+ [Fact]
+ public void TryComputeMaxRateHz_WithOnlySomeConstants_UsesTheTermsItHas()
+ {
+ var model = new CapabilityRateModel { AbsoluteMaximumHz = 16000 };
+
+ Assert.True(model.TryComputeMaxRateHz(4, 16, out var maxRateHz));
+ Assert.Equal(16000, maxRateHz);
+ }
+
+ [Fact]
+ public void TryComputeMaxRateHz_MuxedOnlySelection_IsNotCappedByTheType1Term()
+ {
+ // Dividing the Type-1 aggregate by a zero simultaneous count is undefined; treating the
+ // term as zero would cap a muxed-only selection at 0 Hz. It must simply not apply.
+ var model = new CapabilityRateModel
+ {
+ AbsoluteMaximumHz = 22000,
+ Type1AggregateMaximumHz = 55000,
+ PerTickBudgetHz = 110000,
+ PerTickOverhead = 6
+ };
+
+ Assert.True(model.TryComputeMaxRateHz(0, 4, out var maxRateHz));
+ Assert.Equal(11000, maxRateHz);
+ }
+
+ [Theory]
+ [InlineData(-1, 4)]
+ [InlineData(2, -1)]
+ [InlineData(5, 4)]
+ public void TryComputeMaxRateHz_ImpossibleSelection_Throws(int simultaneousCount, int totalCount)
+ {
+ Assert.Throws(
+ () => BenchModel().TryComputeMaxRateHz(simultaneousCount, totalCount, out _));
+ }
+
+ [Fact]
+ public void TryComputeMaxRateHz_AgreesWithTheDeviceForTheWholeBoard()
+ {
+ // Feed the model the counts derived from the same document's channels[], the way a client
+ // building a rate preview would, and check it lands on the documented rate model rather
+ // than on hand-copied constants.
+ Assert.True(CapabilityDocumentParser.TryParse(
+ CapabilityDocumentSamples.Nyquist1Firmware372, out var document));
+
+ var analogInputs = document!.Channels
+ .Where(c => c.Kind == CapabilityChannelKind.AnalogInput)
+ .ToArray();
+ var simultaneousCount = analogInputs.Count(c => c.IsSimultaneous);
+
+ Assert.True(document.Streaming!.RateModel!.TryComputeMaxRateHz(
+ simultaneousCount, analogInputs.Length, out var maxRateHz));
+
+ // Optimistic by construction — it excludes the transport cap — so it must sit at or above
+ // the conservative envelope and at or below the absolute ceiling.
+ Assert.InRange(maxRateHz, document.Streaming.ConservativeEnvelopeHz!.Value, 22000);
+ }
+}
diff --git a/src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs b/src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs
new file mode 100644
index 00000000..5d46a7d5
--- /dev/null
+++ b/src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs
@@ -0,0 +1,284 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Daqifi.Core.Communication.Messages;
+using Daqifi.Core.Device;
+using Daqifi.Core.Device.Capabilities;
+
+namespace Daqifi.Core.Tests.Device.Capabilities;
+
+///
+/// Tests for — the gating on
+/// and on the reported schema version, and the
+/// requirement that a device which cannot answer is left exactly as it was.
+///
+public class DaqifiDeviceCapabilityDocumentTests
+{
+ private const string ApiVersionCommand = "CONFigure:CAPabilities:APIVersion?";
+ private const string DocumentCommand = "CONFigure:CAPabilities:JSON?";
+
+ private static TestableCapabilityDevice CreateSupportedDevice(string firmwareVersion = "3.7.2")
+ {
+ var device = new TestableCapabilityDevice("BenchNq1");
+ device.Metadata.FirmwareVersion = firmwareVersion;
+ device.Metadata.PartNumber = "Nq1";
+ device.Metadata.DeviceType = DeviceType.Nyquist1;
+ device.Metadata.Capabilities = DeviceCapabilities.FromDeviceType(DeviceType.Nyquist1);
+ device.Connect();
+ return device;
+ }
+
+ [Fact]
+ public async Task ReadCapabilityDocumentAsync_WhenDisconnected_Throws()
+ {
+ var device = new TestableCapabilityDevice("BenchNq1");
+
+ await Assert.ThrowsAsync(
+ () => device.ReadCapabilityDocumentAsync());
+ }
+
+ [Fact]
+ public async Task ReadCapabilityDocumentAsync_OnSupportedDevice_AppliesTheDocument()
+ {
+ var device = CreateSupportedDevice();
+ device.Responses[ApiVersionCommand] = ["2"];
+ device.Responses[DocumentCommand] = [CapabilityDocumentSamples.Nyquist1Firmware372];
+
+ var document = await device.ReadCapabilityDocumentAsync();
+
+ Assert.NotNull(document);
+ Assert.Equal(2, document!.SchemaVersion);
+ Assert.Same(document, device.Metadata.CapabilityDocument);
+ Assert.Equal(16, device.Metadata.Capabilities.AnalogInputChannels);
+ Assert.Equal(16, device.Metadata.Capabilities.DigitalChannels);
+ Assert.Equal(22000, device.Metadata.Capabilities.MaxSamplingRate);
+ Assert.Equal(new[] { ApiVersionCommand, DocumentCommand }, device.SentCommands);
+ }
+
+ [Fact]
+ public async Task ReadCapabilityDocumentAsync_BelowFirmwareFloor_SendsNothingAndChangesNothing()
+ {
+ // The definition-of-done requirement: a device that cannot answer is unaffected. It must
+ // not even be asked — the query does not exist on its firmware.
+ var device = CreateSupportedDevice(firmwareVersion: "3.4.6b1");
+ device.Responses[ApiVersionCommand] = ["2"];
+ device.Responses[DocumentCommand] = [CapabilityDocumentSamples.Nyquist1Firmware372];
+
+ var document = await device.ReadCapabilityDocumentAsync();
+
+ Assert.Null(document);
+ Assert.Null(device.Metadata.CapabilityDocument);
+ Assert.Empty(device.SentCommands);
+ Assert.Equal(1000, device.Metadata.Capabilities.MaxSamplingRate);
+ Assert.Equal(0, device.Metadata.Capabilities.AnalogInputChannels);
+ }
+
+ [Fact]
+ public async Task ReadCapabilityDocumentAsync_WithNoReportedFirmwareVersion_IsSkipped()
+ {
+ // The version axis fails closed (ADR 0001): an unknown version is not permission.
+ var device = CreateSupportedDevice(firmwareVersion: string.Empty);
+
+ Assert.Null(await device.ReadCapabilityDocumentAsync());
+ Assert.Empty(device.SentCommands);
+ }
+
+ [Fact]
+ public async Task ReadCapabilityDocumentAsync_WhenApiVersionQueryFails_DoesNotTrustTheDocument()
+ {
+ // The document arrives in the same exchange, so it is present and parseable — and still
+ // must not be applied, because nothing confirmed the schema it was written to.
+ var device = CreateSupportedDevice();
+ device.Responses[ApiVersionCommand] = ["**ERROR: -113, \"Undefined header\""];
+ device.Responses[DocumentCommand] = [CapabilityDocumentSamples.Nyquist1Firmware372];
+
+ Assert.Null(await device.ReadCapabilityDocumentAsync());
+ Assert.Null(device.Metadata.CapabilityDocument);
+ Assert.Equal(1000, device.Metadata.Capabilities.MaxSamplingRate);
+ }
+
+ [Theory]
+ [InlineData("0")]
+ [InlineData("-1")]
+ public async Task ReadCapabilityDocumentAsync_ApiVersionBelowMinimum_DoesNotTrustTheDocument(
+ string reportedVersion)
+ {
+ var device = CreateSupportedDevice();
+ device.Responses[ApiVersionCommand] = [reportedVersion];
+ device.Responses[DocumentCommand] = [CapabilityDocumentSamples.Nyquist1Firmware372];
+
+ Assert.Null(await device.ReadCapabilityDocumentAsync());
+ Assert.Null(device.Metadata.CapabilityDocument);
+ Assert.Equal(1000, device.Metadata.Capabilities.MaxSamplingRate);
+ }
+
+ [Fact]
+ public async Task ReadCapabilityDocumentAsync_ApiVersionAboveMaximum_DoesNotTrustTheDocument()
+ {
+ // A bumped schema version means a breaking change, so the fields this parser reads may no
+ // longer mean what it assumes. Falling back to the board table beats a plausible-but-wrong
+ // number.
+ var device = CreateSupportedDevice();
+ device.Responses[ApiVersionCommand] =
+ [(DaqifiDevice.MaximumCapabilityDocumentApiVersion + 1).ToString()];
+ device.Responses[DocumentCommand] = [CapabilityDocumentSamples.Nyquist1Firmware372];
+
+ Assert.Null(await device.ReadCapabilityDocumentAsync());
+ Assert.Null(device.Metadata.CapabilityDocument);
+ Assert.Equal(1000, device.Metadata.Capabilities.MaxSamplingRate);
+ }
+
+ [Theory]
+ [InlineData("1")]
+ [InlineData("2")]
+ public async Task ReadCapabilityDocumentAsync_WhenTheTwoReportedSchemaVersionsDisagree_DoesNotApply(
+ string reportedVersion)
+ {
+ // Both replies come back from one exchange, and the firmware emits both versions from a
+ // single macro — so a disagreement means the halves did not come from one coherent
+ // response, and the version that was vetted is not the version of the document in hand.
+ var device = CreateSupportedDevice();
+ device.Responses[ApiVersionCommand] = [reportedVersion];
+ device.Responses[DocumentCommand] =
+ [$"{{\"schema_version\":{(reportedVersion == "1" ? 2 : 1)},"
+ + "\"streaming\":{\"sample_rate_range_hz\":{\"min\":1,\"max\":22000}}}"];
+
+ Assert.Null(await device.ReadCapabilityDocumentAsync());
+ Assert.Null(device.Metadata.CapabilityDocument);
+ Assert.Equal(1000, device.Metadata.Capabilities.MaxSamplingRate);
+ }
+
+ [Fact]
+ public async Task ReadCapabilityDocumentAsync_WhenTheTwoReportedSchemaVersionsAgree_Applies()
+ {
+ var device = CreateSupportedDevice();
+ device.Responses[ApiVersionCommand] = ["1"];
+ device.Responses[DocumentCommand] =
+ ["{\"schema_version\":1,\"streaming\":{\"sample_rate_range_hz\":{\"min\":1,\"max\":16000}}}"];
+
+ var document = await device.ReadCapabilityDocumentAsync();
+
+ Assert.NotNull(document);
+ Assert.Equal(1, document!.SchemaVersion);
+ Assert.Equal(16000, device.Metadata.Capabilities.MaxSamplingRate);
+ }
+
+ [Fact]
+ public async Task ReadCapabilityDocumentAsync_WhenDocumentDoesNotParse_LeavesCapabilitiesAlone()
+ {
+ var device = CreateSupportedDevice();
+ device.Responses[ApiVersionCommand] = ["2"];
+ device.Responses[DocumentCommand] = ["{\"schema_version\":2, truncated"];
+
+ Assert.Null(await device.ReadCapabilityDocumentAsync());
+ Assert.Null(device.Metadata.CapabilityDocument);
+ Assert.Equal(1000, device.Metadata.Capabilities.MaxSamplingRate);
+ Assert.True(device.Metadata.Capabilities.HasSdCard);
+ }
+
+ [Fact]
+ public async Task ReadCapabilityDocumentAsync_SurvivesALaterStatusMessage()
+ {
+ // Status messages repeat the part number. Before this change each one rebuilt Capabilities
+ // from the board table, which would have discarded the document mid-session.
+ var device = CreateSupportedDevice();
+ device.Responses[ApiVersionCommand] = ["2"];
+ device.Responses[DocumentCommand] = [CapabilityDocumentSamples.Nyquist1Firmware372];
+
+ await device.ReadCapabilityDocumentAsync();
+ device.Metadata.UpdateFromProtobuf(new DaqifiOutMessage { DevicePn = "Nq1" });
+
+ Assert.Equal(22000, device.Metadata.Capabilities.MaxSamplingRate);
+ Assert.Equal(16, device.Metadata.Capabilities.AnalogInputChannels);
+ }
+
+ [Fact]
+ public async Task ReadCapabilityDocumentAsync_DoesNotChangeWhatSupportsReports()
+ {
+ // No new refusals on the existing Supports() seam: overlaying the document must leave
+ // every feature answering exactly as it did from the board table alone.
+ var device = CreateSupportedDevice();
+ var before = Enum.GetValues().ToDictionary(f => f, device.Supports);
+
+ device.Responses[ApiVersionCommand] = ["2"];
+ device.Responses[DocumentCommand] = [CapabilityDocumentSamples.Nyquist1Firmware372];
+ await device.ReadCapabilityDocumentAsync();
+
+ foreach (var (feature, supported) in before)
+ {
+ Assert.Equal(supported, device.Supports(feature));
+ }
+ }
+
+ ///
+ /// A device whose text-command exchange answers from a per-command script, so the
+ /// capability read can be driven without a transport (mirrors TestableLanChipInfoDevice).
+ ///
+ private sealed class TestableCapabilityDevice : DaqifiDevice
+ {
+ public TestableCapabilityDevice(string name)
+ : base(name)
+ {
+ }
+
+ /// Commands the device was actually asked, in order.
+ public List SentCommands { get; } = new();
+
+ /// Response lines keyed by the command that triggers them.
+ public Dictionary Responses { get; } = new();
+
+ public override void Send(IOutboundMessage message)
+ {
+ if (message is IOutboundMessage stringMessage)
+ {
+ SentCommands.Add(stringMessage.Data);
+ }
+ }
+
+ protected override async Task> ExecuteTextCommandAsync(
+ Action setupAction,
+ int responseTimeoutMs = 1000,
+ int completionTimeoutMs = 250,
+ CancellationToken cancellationToken = default,
+ Func? prepareAsync = null)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // Honor the exchange's prepare phase the way the real device does: it runs first,
+ // before anything this exchange sends (#396).
+ if (prepareAsync != null)
+ {
+ await prepareAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ var before = SentCommands.Count;
+ setupAction();
+ return ResponsesSince(before);
+ }
+
+ protected override async Task> ExecuteTextCommandAsync(
+ Func setupActionAsync,
+ int responseTimeoutMs = 1000,
+ int completionTimeoutMs = 250,
+ CancellationToken cancellationToken = default)
+ {
+ var before = SentCommands.Count;
+ await setupActionAsync(cancellationToken).ConfigureAwait(false);
+ return ResponsesSince(before);
+ }
+
+ ///
+ /// Concatenates the scripted replies for every command sent during one exchange, in the
+ /// order they were sent — the device answers a batched exchange the same way.
+ ///
+ private IReadOnlyList ResponsesSince(int firstCommandIndex) =>
+ SentCommands
+ .Skip(firstCommandIndex)
+ .SelectMany(command => Responses.TryGetValue(command, out var response)
+ ? response
+ : Array.Empty())
+ .ToList();
+ }
+}
diff --git a/src/Daqifi.Core.Tests/Device/DeviceMetadataTests.cs b/src/Daqifi.Core.Tests/Device/DeviceMetadataTests.cs
index 058d13f9..d686627f 100644
--- a/src/Daqifi.Core.Tests/Device/DeviceMetadataTests.cs
+++ b/src/Daqifi.Core.Tests/Device/DeviceMetadataTests.cs
@@ -1,4 +1,6 @@
+using System;
using Daqifi.Core.Device;
+using Daqifi.Core.Device.Capabilities;
using Google.Protobuf;
using Xunit;
@@ -50,6 +52,149 @@ public void UpdateFromProtobuf_UpdatesPartNumberAndDeviceType()
Assert.True(metadata.Capabilities.HasWiFi);
}
+ [Fact]
+ public void UpdateFromProtobuf_RepeatedPartNumber_DoesNotDiscardChannelCounts()
+ {
+ // Status messages repeat the part number. Rebuilding the board-derived capabilities on
+ // each one reset the channel counts, which a status message carrying no port fields does
+ // not restore.
+ var metadata = new DeviceMetadata();
+ metadata.UpdateFromProtobuf(new DaqifiOutMessage
+ {
+ DevicePn = "Nq1",
+ AnalogInPortNum = 16,
+ DigitalPortNum = 16
+ });
+
+ metadata.UpdateFromProtobuf(new DaqifiOutMessage { DevicePn = "Nq1" });
+
+ Assert.Equal(16, metadata.Capabilities.AnalogInputChannels);
+ Assert.Equal(16, metadata.Capabilities.DigitalChannels);
+ }
+
+ [Fact]
+ public void UpdateFromProtobuf_ChangedPartNumber_RebuildsBoardDerivedCapabilities()
+ {
+ // The board really did change (a reconnect against a different unit on the same object),
+ // so the board-derived values must be rebuilt rather than carried over.
+ var metadata = new DeviceMetadata();
+ metadata.UpdateFromProtobuf(new DaqifiOutMessage { DevicePn = "Nq1", AnalogOutPortNum = 4 });
+
+ metadata.UpdateFromProtobuf(new DaqifiOutMessage { DevicePn = "Nq3" });
+
+ Assert.Equal(DeviceType.Nyquist3, metadata.DeviceType);
+ Assert.Equal(0, metadata.Capabilities.AnalogOutputChannels);
+ }
+
+ [Fact]
+ public void UpdateFromProtobuf_BoardAssignedButCapabilitiesNeverDerived_StillDerivesThem()
+ {
+ // DeviceType has a public setter, so "the status message reports the board we already
+ // have" is not the same as "the capabilities were built for that board".
+ var metadata = new DeviceMetadata { DeviceType = DeviceType.Nyquist1 };
+
+ metadata.UpdateFromProtobuf(new DaqifiOutMessage { DevicePn = "Nq1" });
+
+ Assert.True(metadata.Capabilities.HasSdCard);
+ Assert.True(metadata.Capabilities.HasWiFi);
+ Assert.True(metadata.Capabilities.HasUsb);
+ }
+
+ [Fact]
+ public void ApplyCapabilityDocument_OverlaysDocumentAndSurvivesLaterStatusMessages()
+ {
+ var metadata = new DeviceMetadata();
+ metadata.UpdateFromProtobuf(new DaqifiOutMessage { DevicePn = "Nq1", AnalogInPortNum = 16 });
+
+ Assert.True(CapabilityDocumentParser.TryParse(
+ "{\"schema_version\":2,\"streaming\":{\"sample_rate_range_hz\":{\"min\":1,\"max\":22000}}}",
+ out var document));
+ metadata.ApplyCapabilityDocument(document!);
+
+ Assert.Same(document, metadata.CapabilityDocument);
+ Assert.Equal(22000, metadata.Capabilities.MaxSamplingRate);
+
+ // A later status message must not revert the device's own value to the board table's.
+ metadata.UpdateFromProtobuf(new DaqifiOutMessage { DevicePn = "Nq1", AnalogInPortNum = 16 });
+
+ Assert.Equal(22000, metadata.Capabilities.MaxSamplingRate);
+ Assert.Equal(16, metadata.Capabilities.AnalogInputChannels);
+ }
+
+ [Fact]
+ public void UpdateFromProtobuf_ChangedPartNumber_DiscardsTheOldBoardsCapabilityDocument()
+ {
+ // The overlay is durable across status messages by design, but it describes the board it
+ // was read from — carrying it onto a different unit connected through the same object
+ // would overlay the previous device's flags, channel counts and rate ceiling.
+ var metadata = new DeviceMetadata();
+ metadata.UpdateFromProtobuf(new DaqifiOutMessage { DevicePn = "Nq1" });
+ Assert.True(CapabilityDocumentParser.TryParse(
+ "{\"schema_version\":2,\"streaming\":{\"sample_rate_range_hz\":{\"min\":1,\"max\":22000}},"
+ + "\"channels\":[{\"id\":0,\"kind\":\"analog-input\"}]}",
+ out var document));
+ metadata.ApplyCapabilityDocument(document!);
+ Assert.Equal(22000, metadata.Capabilities.MaxSamplingRate);
+
+ metadata.UpdateFromProtobuf(new DaqifiOutMessage { DevicePn = "Nq3" });
+
+ Assert.Null(metadata.CapabilityDocument);
+ Assert.Equal(1000, metadata.Capabilities.MaxSamplingRate);
+ Assert.Equal(0, metadata.Capabilities.AnalogInputChannels);
+ }
+
+ [Theory]
+ // Unknown board first (an unrecognized part number), then the real one — a discovery.
+ [InlineData("Xx9", "Nq1")]
+ // The real board first, then an unrecognized part number — says nothing about a swap.
+ [InlineData("Nq1", "Xx9")]
+ public void UpdateFromProtobuf_TransitionInvolvingUnknownBoard_KeepsTheCapabilityDocument(
+ string firstPartNumber, string secondPartNumber)
+ {
+ // Per ADR 0001, Unknown means "not yet known", not "a different board". The device that
+ // answered the capability query is the same one either way, so discarding the overlay
+ // would regress MaxSamplingRate to the stale board-table ceiling for no gain.
+ var metadata = new DeviceMetadata();
+ metadata.UpdateFromProtobuf(new DaqifiOutMessage { DevicePn = firstPartNumber });
+ Assert.True(CapabilityDocumentParser.TryParse(
+ "{\"schema_version\":2,\"streaming\":{\"sample_rate_range_hz\":{\"min\":1,\"max\":22000}}}",
+ out var document));
+ metadata.ApplyCapabilityDocument(document!);
+
+ metadata.UpdateFromProtobuf(new DaqifiOutMessage { DevicePn = secondPartNumber });
+
+ Assert.Same(document, metadata.CapabilityDocument);
+ Assert.Equal(22000, metadata.Capabilities.MaxSamplingRate);
+ }
+
+ [Fact]
+ public void ApplyCapabilityDocument_NullDocument_Throws()
+ {
+ var metadata = new DeviceMetadata();
+
+ Assert.Throws(() => metadata.ApplyCapabilityDocument(null!));
+ }
+
+ [Fact]
+ public void CopyFrom_CarriesTheCapabilityDocument()
+ {
+ // Without it, the target's next status message would rebuild from the board table with
+ // nothing to re-overlay, silently discarding the device's own values.
+ var source = new DeviceMetadata();
+ source.UpdateFromProtobuf(new DaqifiOutMessage { DevicePn = "Nq1" });
+ Assert.True(CapabilityDocumentParser.TryParse(
+ "{\"schema_version\":2,\"streaming\":{\"sample_rate_range_hz\":{\"min\":1,\"max\":22000}}}",
+ out var document));
+ source.ApplyCapabilityDocument(document!);
+
+ var target = new DeviceMetadata();
+ target.CopyFrom(source);
+ target.UpdateFromProtobuf(new DaqifiOutMessage { DevicePn = "Nq1" });
+
+ Assert.Same(document, target.CapabilityDocument);
+ Assert.Equal(22000, target.Capabilities.MaxSamplingRate);
+ }
+
[Fact]
public void UpdateFromProtobuf_UpdatesSerialNumber()
{
diff --git a/src/Daqifi.Core/Communication/Producers/ScpiMessageProducer.cs b/src/Daqifi.Core/Communication/Producers/ScpiMessageProducer.cs
index f0301cef..066e3c51 100644
--- a/src/Daqifi.Core/Communication/Producers/ScpiMessageProducer.cs
+++ b/src/Daqifi.Core/Communication/Producers/ScpiMessageProducer.cs
@@ -120,6 +120,37 @@ public static IOutboundMessage SetDeviceName(string? name)
///
public static IOutboundMessage GetSystemError => new ScpiMessage("SYSTem:ERRor?");
+ ///
+ /// Creates a query message to read the schema version of the device's capability document.
+ ///
+ ///
+ /// Returns a single unsigned integer — the capability schema version byte. The firmware bumps
+ /// it only on a breaking schema change (field rename/removal, type change, semantics
+ /// change, layout reshape); additive fields do not bump it. Issue this before
+ /// and dispatch on the result rather than assuming the
+ /// document's shape is stable across firmware versions.
+ /// Requires firmware v3.5.0 or newer ().
+ /// Command: CONFigure:CAPabilities:APIVersion?
+ /// Example: messageProducer.Send(ScpiMessageProducer.GetCapabilitiesApiVersion);
+ ///
+ public static IOutboundMessage GetCapabilitiesApiVersion =>
+ new ScpiMessage("CONFigure:CAPabilities:APIVersion?");
+
+ ///
+ /// Creates a query message to read the device's capability document.
+ ///
+ ///
+ /// Returns the device's self-description as a single line of JSON (several kilobytes on a
+ /// 16-channel board): identity, a flat channels[] array, and streaming,
+ /// storage, power, transports and triggers blocks. Parse it with
+ /// .
+ /// Requires firmware v3.5.0 or newer ().
+ /// Command: CONFigure:CAPabilities:JSON?
+ /// Example: messageProducer.Send(ScpiMessageProducer.GetCapabilitiesJson);
+ ///
+ public static IOutboundMessage GetCapabilitiesJson =>
+ new ScpiMessage("CONFigure:CAPabilities:JSON?");
+
///
/// Creates a command message to force the device into bootloader mode.
///
@@ -362,9 +393,15 @@ public static IOutboundMessage SetStreamInterface(StreamInterface stream
///
/// Creates a command message to start data streaming at the specified frequency.
///
- /// The streaming frequency in Hz (1-1000).
+ /// The streaming frequency in Hz.
///
- /// Starts streaming data from enabled channels at the specified frequency.
+ /// Starts streaming data from enabled channels at the specified frequency. The usable range is
+ /// board- and configuration-dependent, not a fixed literal: the device's absolute ceiling is
+ /// , and the cap for the channel set
+ /// currently enabled is
+ /// . Firmware
+ /// rejects a frequency above that cap with SCPI -222 and does not start streaming — it
+ /// does not clamp — so pre-validate or handle the error.
/// Command: SYSTem:StartStreamData frequency
/// Example: messageProducer.Send(ScpiMessageProducer.StartStreaming(100)); // Stream at 100Hz
///
diff --git a/src/Daqifi.Core/Device/Capabilities/CapabilityChannel.cs b/src/Daqifi.Core/Device/Capabilities/CapabilityChannel.cs
new file mode 100644
index 00000000..5d67e157
--- /dev/null
+++ b/src/Daqifi.Core/Device/Capabilities/CapabilityChannel.cs
@@ -0,0 +1,76 @@
+namespace Daqifi.Core.Device.Capabilities;
+
+///
+/// One entry of the capability document's flat channels[] array.
+///
+///
+/// Every property except and is nullable or defaulted, because
+/// the schema emits a different property set per (an analog input carries
+/// ranges and calibration; a digital pin carries a features object) and because a field the
+/// document omits must read as "not stated", never as a value.
+///
+public sealed class CapabilityChannel
+{
+ ///
+ /// Gets the channel's device-facing index. Unique only within a — the
+ /// document numbers analog inputs and digital pins from 0 independently.
+ ///
+ public int Id { get; init; }
+
+ /// Gets the channel kind.
+ public CapabilityChannelKind Kind { get; init; }
+
+ ///
+ /// Gets the raw kind string as emitted by the device, retained so an unrecognized kind
+ /// () is still diagnosable.
+ ///
+ public string? RawKind { get; init; }
+
+ /// Gets the measured quantity, e.g. "voltage" or "temperature".
+ public string? SignalType { get; init; }
+
+ /// Gets the unit for , e.g. "V" or "Cel".
+ public string? Unit { get; init; }
+
+ /// Gets the converter resolution in bits, or null when not stated.
+ public int? ResolutionBits { get; init; }
+
+ ///
+ /// Gets a value indicating whether the channel is sampled by a dedicated converter (zero
+ /// inter-channel skew) rather than through a shared multiplexer. This is the "Type-1" count
+ /// that feeds .
+ ///
+ public bool IsSimultaneous { get; init; }
+
+ /// Gets a value indicating whether the channel can be configured as differential.
+ public bool IsDifferential { get; init; }
+
+ /// Gets the low end of the channel's terminal range, or null when not stated.
+ public double? RangeMinimum { get; init; }
+
+ /// Gets the high end of the channel's terminal range, or null when not stated.
+ public double? RangeMaximum { get; init; }
+
+ ///
+ /// Gets a value indicating whether a pin
+ /// advertises PWM output. The schema uses key presence for this: a pin without PWM simply
+ /// omits the pwm object from its features.
+ ///
+ public bool SupportsPwm { get; init; }
+
+ /// Gets the lowest PWM frequency the pin accepts, or null when not stated.
+ public int? PwmMinimumFrequencyHz { get; init; }
+
+ /// Gets the highest PWM frequency the pin accepts, or null when not stated.
+ public int? PwmMaximumFrequencyHz { get; init; }
+
+ ///
+ /// Gets the device-reported linear calibration slope, or null when not stated. Together
+ /// with this is the device's own raw-to-engineering-units
+ /// conversion.
+ ///
+ public double? CalibrationSlope { get; init; }
+
+ /// Gets the device-reported linear calibration intercept, or null when not stated.
+ public double? CalibrationIntercept { get; init; }
+}
diff --git a/src/Daqifi.Core/Device/Capabilities/CapabilityChannelKind.cs b/src/Daqifi.Core/Device/Capabilities/CapabilityChannelKind.cs
new file mode 100644
index 00000000..1ab85700
--- /dev/null
+++ b/src/Daqifi.Core/Device/Capabilities/CapabilityChannelKind.cs
@@ -0,0 +1,25 @@
+namespace Daqifi.Core.Device.Capabilities;
+
+///
+/// The kind of an entry in the capability document's flat channels[] array.
+///
+///
+/// The firmware schema is deliberately open-ended: new kind values are an additive change
+/// that does not bump the schema version, and clients are required to ignore ones they do not
+/// recognize. Unrecognized values therefore map to rather than failing the
+/// parse.
+///
+public enum CapabilityChannelKind
+{
+ /// A kind this version of daqifi-core does not recognize.
+ Unknown = 0,
+
+ /// "analog-input".
+ AnalogInput,
+
+ /// "analog-output".
+ AnalogOutput,
+
+ /// "digital-io".
+ DigitalIo
+}
diff --git a/src/Daqifi.Core/Device/Capabilities/CapabilityDocument.cs b/src/Daqifi.Core/Device/Capabilities/CapabilityDocument.cs
new file mode 100644
index 00000000..137c2be4
--- /dev/null
+++ b/src/Daqifi.Core/Device/Capabilities/CapabilityDocument.cs
@@ -0,0 +1,169 @@
+using System;
+using System.Collections.Generic;
+
+namespace Daqifi.Core.Device.Capabilities;
+
+///
+/// A device's self-description, as returned by CONFigure:CAPabilities:JSON? on firmware
+/// v3.5.0 and newer. Parse one with .
+///
+///
+///
+/// This is the live counterpart to . It does not
+/// replace it: the board-derived table stays the bootstrap (it answers before the device has been
+/// asked anything, and for firmware that cannot answer at all) and the permanent fallback for
+/// anything the document omits. overlays only the fields the document
+/// actually stated — see ADR 0001, docs/adr/0001-firmware-feature-gating.md.
+///
+///
+/// Every field is nullable or defaulted on purpose. The firmware's schema rules make additive
+/// change the norm — new fields and new channel kinds appear without a schema-version bump — so a
+/// property that is null means "this document did not state it", never "the device does not
+/// have it".
+///
+///
+public sealed class CapabilityDocument
+{
+ ///
+ /// Gets the document's schema version, as carried in the document body. The firmware bumps
+ /// this only on a breaking change; it is the same value
+ /// CONFigure:CAPabilities:APIVersion? returns.
+ ///
+ public int SchemaVersion { get; init; }
+
+ /// Gets the schema URI the document declares, or null when not stated.
+ public string? SchemaUri { get; init; }
+
+ /// Gets the device identity block, or null when the document omitted it.
+ public CapabilityIdentity? Identity { get; init; }
+
+ ///
+ /// Gets the device's channels — analog inputs, analog outputs and digital I/O in one flat
+ /// list. Empty when the document omitted the array.
+ ///
+ public IReadOnlyList Channels { get; init; } = Array.Empty();
+
+ /// Gets the streaming block, or null when the document omitted it.
+ public CapabilityStreaming? Streaming { get; init; }
+
+ ///
+ /// Gets whether the board is fitted with SD-card hardware, or null when not stated.
+ /// Structural, not runtime: it does not say whether a card is currently inserted.
+ ///
+ public bool? SdSupported { get; init; }
+
+ /// Gets whether the board supports USB, or null when not stated.
+ public bool? UsbSupported { get; init; }
+
+ /// Gets whether the board supports WiFi, or null when not stated.
+ public bool? WifiSupported { get; init; }
+
+ /// Gets whether the board supports Ethernet, or null when not stated.
+ public bool? EthernetSupported { get; init; }
+
+ /// Gets whether the board has a battery fitted, or null when not stated.
+ public bool? BatteryPresent { get; init; }
+
+ /// Gets whether the board accepts external power, or null when not stated.
+ public bool? ExternalPowerSupported { get; init; }
+
+ ///
+ /// Gets the document exactly as the device emitted it, for diagnostics and support — the
+ /// parsed properties above cover the client-actionable subset, not every field in the schema.
+ ///
+ public string RawJson { get; init; } = string.Empty;
+
+ ///
+ /// Counts the channels of one .
+ ///
+ /// The kind to count.
+ /// The number of entries of that kind.
+ public int CountChannels(CapabilityChannelKind kind)
+ {
+ var count = 0;
+ for (var i = 0; i < Channels.Count; i++)
+ {
+ if (Channels[i].Kind == kind)
+ {
+ count++;
+ }
+ }
+
+ return count;
+ }
+
+ ///
+ /// Overlays this document's values onto board-derived ,
+ /// leaving every field the document did not state untouched.
+ ///
+ ///
+ ///
+ /// Merge, not replace. The caller's instance keeps its board-derived values as the floor, so a
+ /// device that answers partially — or a firmware whose schema drops a field we used to read —
+ /// degrades to the static table rather than to zeros. This is also what preserves the
+ /// " means not-yet-known, not absent" rule that
+ /// depends on: an absent field never turns a
+ /// hardware flag off.
+ ///
+ ///
+ /// Two fields are deliberately never overlaid:
+ ///
+ ///
+ /// -
+ /// — the schema carries no chipset
+ /// information by design (it publishes what a client can do, not what parts are
+ /// fitted), so this stays board-derived.
+ ///
+ /// -
+ /// — the firmware emits the
+ /// streaming block unconditionally and has no "streaming supported" boolean, so the
+ /// block's presence would not be evidence either way.
+ ///
+ ///
+ ///
+ /// The channel counts are overlaid as a set, and only when is non-empty.
+ /// The schema defines channels[] as the board's complete channel list, so once it is
+ /// present a count of zero for a kind is a real answer (an NQ1 genuinely has no analog
+ /// outputs) rather than a gap.
+ ///
+ ///
+ /// The board-derived capabilities to overlay onto.
+ /// is null.
+ public void MergeInto(DeviceCapabilities capabilities)
+ {
+ ArgumentNullException.ThrowIfNull(capabilities);
+
+ if (SdSupported.HasValue)
+ {
+ capabilities.HasSdCard = SdSupported.Value;
+ }
+
+ if (UsbSupported.HasValue)
+ {
+ capabilities.HasUsb = UsbSupported.Value;
+ }
+
+ if (WifiSupported.HasValue)
+ {
+ capabilities.HasWiFi = WifiSupported.Value;
+ }
+
+ if (Channels.Count > 0)
+ {
+ capabilities.AnalogInputChannels = CountChannels(CapabilityChannelKind.AnalogInput);
+ capabilities.AnalogOutputChannels = CountChannels(CapabilityChannelKind.AnalogOutput);
+ capabilities.DigitalChannels = CountChannels(CapabilityChannelKind.DigitalIo);
+ }
+
+ // The absolute ISR ceiling, not the current or conservative rate: MaxSamplingRate is the
+ // board's upper bound (DaqifiStreamingDevice validates the requested frequency against it),
+ // and the other two figures move with the enabled channel set. A client that needs the
+ // achievable rate for a specific configuration reads CurrentMaximumRateHz or evaluates
+ // RateModel; the device rejects an over-ask outright, so this bound is a sanity check
+ // rather than the authority.
+ if (Streaming?.MaximumSampleRateHz > 0)
+ {
+ capabilities.MaxSamplingRate = Streaming.MaximumSampleRateHz.Value;
+ }
+ }
+}
diff --git a/src/Daqifi.Core/Device/Capabilities/CapabilityDocumentParser.cs b/src/Daqifi.Core/Device/Capabilities/CapabilityDocumentParser.cs
new file mode 100644
index 00000000..f7881e81
--- /dev/null
+++ b/src/Daqifi.Core/Device/Capabilities/CapabilityDocumentParser.cs
@@ -0,0 +1,400 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.Text.Json;
+
+namespace Daqifi.Core.Device.Capabilities;
+
+///
+/// Parses the responses to CONFigure:CAPabilities:JSON? and
+/// CONFigure:CAPabilities:APIVersion?.
+///
+///
+///
+/// Tolerant by design. Every parse method returns false instead of throwing, and every
+/// optional field is read through a helper that yields null when the field is absent or has
+/// an unexpected type. That is what lets an unfamiliar firmware revision degrade to the
+/// board-derived capability table (ADR 0001) rather than fail a device's initialization: the
+/// firmware's schema rules make additive change routine, and unknown fields must be ignored.
+///
+///
+public static class CapabilityDocumentParser
+{
+ ///
+ /// Attempts to parse a capability document from a single JSON string.
+ ///
+ /// The JSON document as emitted by CONFigure:CAPabilities:JSON?.
+ /// The parsed document, or null when parsing failed.
+ /// true when a capability document was parsed.
+ public static bool TryParse(string? json, [NotNullWhen(true)] out CapabilityDocument? document)
+ {
+ document = null;
+
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return false;
+ }
+
+ try
+ {
+ using var parsed = JsonDocument.Parse(json);
+ var root = parsed.RootElement;
+
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ return false;
+ }
+
+ // Require the schema version. It is the one field the firmware always emits, so
+ // demanding it distinguishes a capability document from any other JSON that might
+ // arrive on the same text channel — and a rename would itself be a breaking change.
+ var schemaVersion = ReadInt(root, "schema_version");
+ if (!schemaVersion.HasValue)
+ {
+ return false;
+ }
+
+ var hasStorage = root.TryGetProperty("storage", out var storage)
+ && storage.ValueKind == JsonValueKind.Object;
+ var hasPower = root.TryGetProperty("power", out var power)
+ && power.ValueKind == JsonValueKind.Object;
+
+ document = new CapabilityDocument
+ {
+ SchemaVersion = schemaVersion.Value,
+ SchemaUri = ReadString(root, "schema_uri"),
+ Identity = ReadIdentity(root),
+ Channels = ReadChannels(root),
+ Streaming = ReadStreaming(root),
+ SdSupported = hasStorage ? ReadBool(storage, "sd_supported") : null,
+ UsbSupported = ReadTransportSupported(root, "usb"),
+ WifiSupported = ReadTransportSupported(root, "wifi"),
+ EthernetSupported = ReadTransportSupported(root, "ethernet"),
+ BatteryPresent = hasPower ? ReadBool(power, "battery_present") : null,
+ ExternalPowerSupported = hasPower ? ReadBool(power, "external_power_supported") : null,
+ RawJson = json
+ };
+
+ return true;
+ }
+ catch (JsonException)
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Attempts to parse a capability document from a device's text response lines, trying each
+ /// line that looks like a JSON object until one parses.
+ ///
+ ///
+ /// The response can also contain the command echo and the device prompt, so lines are filtered
+ /// rather than assumed to be the document.
+ ///
+ /// Response lines from the device.
+ /// The parsed document, or null when no line parsed.
+ /// true when a capability document was parsed.
+ /// is null.
+ public static bool TryParseLines(
+ IEnumerable lines,
+ [NotNullWhen(true)] out CapabilityDocument? document)
+ {
+ ArgumentNullException.ThrowIfNull(lines);
+
+ document = null;
+
+ foreach (var line in lines)
+ {
+ if (string.IsNullOrWhiteSpace(line))
+ {
+ continue;
+ }
+
+ var trimmed = line.Trim();
+ if (trimmed.Length == 0 || trimmed[0] != '{')
+ {
+ continue;
+ }
+
+ if (TryParse(trimmed, out document))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Attempts to read the capability schema version from the response lines of
+ /// CONFigure:CAPabilities:APIVersion?.
+ ///
+ ///
+ /// The response is a bare integer, but it can be accompanied by the command echo, the device
+ /// prompt, or a SCPI error line on firmware that does not implement the query — so the first
+ /// line that is entirely an integer wins, and error lines are skipped explicitly.
+ ///
+ /// Response lines from the device.
+ /// The reported schema version, or 0 when none was found.
+ /// true when a version was read.
+ /// is null.
+ public static bool TryParseApiVersion(IEnumerable lines, out int apiVersion)
+ {
+ ArgumentNullException.ThrowIfNull(lines);
+
+ apiVersion = 0;
+
+ foreach (var line in lines)
+ {
+ if (string.IsNullOrWhiteSpace(line) || ScpiResponseClassifier.IsErrorResponseLine(line))
+ {
+ continue;
+ }
+
+ if (int.TryParse(
+ line.Trim(),
+ NumberStyles.Integer,
+ CultureInfo.InvariantCulture,
+ out var value))
+ {
+ apiVersion = value;
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static CapabilityIdentity? ReadIdentity(JsonElement root)
+ {
+ if (!root.TryGetProperty("identity", out var identity) || identity.ValueKind != JsonValueKind.Object)
+ {
+ return null;
+ }
+
+ return new CapabilityIdentity
+ {
+ Vendor = ReadString(identity, "vendor"),
+ Model = ReadString(identity, "model"),
+ Variant = ReadString(identity, "variant"),
+ Serial = ReadString(identity, "serial"),
+ FirmwareRevision = ReadString(identity, "firmware_rev"),
+ HardwareRevision = ReadString(identity, "hardware_rev")
+ };
+ }
+
+ private static IReadOnlyList ReadChannels(JsonElement root)
+ {
+ if (!root.TryGetProperty("channels", out var channels) || channels.ValueKind != JsonValueKind.Array)
+ {
+ return Array.Empty();
+ }
+
+ var result = new List(channels.GetArrayLength());
+ foreach (var element in channels.EnumerateArray())
+ {
+ if (element.ValueKind != JsonValueKind.Object)
+ {
+ continue;
+ }
+
+ var id = ReadInt(element, "id");
+ var rawKind = ReadString(element, "kind");
+ if (!id.HasValue || rawKind == null)
+ {
+ // An entry with no identity is unusable; skip it rather than inventing an index,
+ // which would corrupt the per-kind counts the merge relies on.
+ continue;
+ }
+
+ var range = ReadFirstRange(element);
+ var (supportsPwm, pwmMin, pwmMax) = ReadPwm(element);
+ var (slope, intercept) = ReadCalibration(element);
+
+ result.Add(new CapabilityChannel
+ {
+ Id = id.Value,
+ Kind = ParseKind(rawKind),
+ RawKind = rawKind,
+ SignalType = ReadString(element, "signal_type"),
+ Unit = ReadString(element, "unit"),
+ ResolutionBits = ReadInt(element, "resolution_bits"),
+ IsSimultaneous = ReadBool(element, "simultaneous") ?? false,
+ IsDifferential = ReadBool(element, "differential") ?? false,
+ RangeMinimum = range.Minimum,
+ RangeMaximum = range.Maximum,
+ SupportsPwm = supportsPwm,
+ PwmMinimumFrequencyHz = pwmMin,
+ PwmMaximumFrequencyHz = pwmMax,
+ CalibrationSlope = slope,
+ CalibrationIntercept = intercept
+ });
+ }
+
+ return result;
+ }
+
+ private static CapabilityChannelKind ParseKind(string rawKind) => rawKind switch
+ {
+ "analog-input" => CapabilityChannelKind.AnalogInput,
+ "analog-output" => CapabilityChannelKind.AnalogOutput,
+ "digital-io" => CapabilityChannelKind.DigitalIo,
+ _ => CapabilityChannelKind.Unknown
+ };
+
+ private static (double? Minimum, double? Maximum) ReadFirstRange(JsonElement channel)
+ {
+ if (!channel.TryGetProperty("ranges", out var ranges) || ranges.ValueKind != JsonValueKind.Array)
+ {
+ return (null, null);
+ }
+
+ foreach (var range in ranges.EnumerateArray())
+ {
+ if (range.ValueKind == JsonValueKind.Object)
+ {
+ return (ReadDouble(range, "min"), ReadDouble(range, "max"));
+ }
+ }
+
+ return (null, null);
+ }
+
+ private static (bool SupportsPwm, int? MinimumHz, int? MaximumHz) ReadPwm(JsonElement channel)
+ {
+ // The schema signals digital-pin features by key presence: a pin without PWM simply omits
+ // the "pwm" object, so absence is the negative answer rather than a false-valued flag.
+ if (!channel.TryGetProperty("features", out var features)
+ || features.ValueKind != JsonValueKind.Object
+ || !features.TryGetProperty("pwm", out var pwm)
+ || pwm.ValueKind != JsonValueKind.Object)
+ {
+ return (false, null, null);
+ }
+
+ return (true, ReadInt(pwm, "min_freq_hz"), ReadInt(pwm, "max_freq_hz"));
+ }
+
+ private static (double? Slope, double? Intercept) ReadCalibration(JsonElement channel)
+ {
+ if (!channel.TryGetProperty("calibration", out var calibration)
+ || calibration.ValueKind != JsonValueKind.Object)
+ {
+ return (null, null);
+ }
+
+ return (ReadDouble(calibration, "slope"), ReadDouble(calibration, "intercept"));
+ }
+
+ private static CapabilityStreaming? ReadStreaming(JsonElement root)
+ {
+ if (!root.TryGetProperty("streaming", out var streaming) || streaming.ValueKind != JsonValueKind.Object)
+ {
+ return null;
+ }
+
+ int? minimumRate = null;
+ int? maximumRate = null;
+ if (streaming.TryGetProperty("sample_rate_range_hz", out var range)
+ && range.ValueKind == JsonValueKind.Object)
+ {
+ minimumRate = ReadInt(range, "min");
+ maximumRate = ReadInt(range, "max");
+ }
+
+ return new CapabilityStreaming
+ {
+ MinimumSampleRateHz = minimumRate,
+ MaximumSampleRateHz = maximumRate,
+ ConservativeEnvelopeHz = ReadInt(streaming, "conservative_envelope_hz"),
+ CurrentMaximumRateHz = ReadInt(streaming, "current_max_rate_hz"),
+ RateValidation = ReadString(streaming, "rate_validation"),
+ RateModel = ReadRateModel(streaming),
+ Encodings = ReadStringArray(streaming, "encodings"),
+ Transports = ReadStringArray(streaming, "transports")
+ };
+ }
+
+ private static CapabilityRateModel? ReadRateModel(JsonElement streaming)
+ {
+ if (!streaming.TryGetProperty("rate_model", out var model) || model.ValueKind != JsonValueKind.Object)
+ {
+ return null;
+ }
+
+ return new CapabilityRateModel
+ {
+ Formula = ReadString(model, "formula"),
+ AbsoluteMaximumHz = ReadInt(model, "absolute_max_hz"),
+ Type1AggregateMaximumHz = ReadInt(model, "type1_aggregate_max_hz"),
+ PerTickBudgetHz = ReadInt(model, "per_tick_budget_hz"),
+ PerTickOverhead = ReadInt(model, "per_tick_overhead")
+ };
+ }
+
+ private static bool? ReadTransportSupported(JsonElement root, string transportName)
+ {
+ if (!root.TryGetProperty("transports", out var transports)
+ || transports.ValueKind != JsonValueKind.Object
+ || !transports.TryGetProperty(transportName, out var transport)
+ || transport.ValueKind != JsonValueKind.Object)
+ {
+ return null;
+ }
+
+ return ReadBool(transport, "supported");
+ }
+
+ private static string? ReadString(JsonElement parent, string name) =>
+ parent.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
+ ? value.GetString()
+ : null;
+
+ private static int? ReadInt(JsonElement parent, string name) =>
+ parent.TryGetProperty(name, out var value)
+ && value.ValueKind == JsonValueKind.Number
+ && value.TryGetInt32(out var parsedValue)
+ ? parsedValue
+ : null;
+
+ private static double? ReadDouble(JsonElement parent, string name) =>
+ parent.TryGetProperty(name, out var value)
+ && value.ValueKind == JsonValueKind.Number
+ && value.TryGetDouble(out var parsedValue)
+ ? parsedValue
+ : null;
+
+ private static bool? ReadBool(JsonElement parent, string name) =>
+ parent.TryGetProperty(name, out var value)
+ ? value.ValueKind switch
+ {
+ JsonValueKind.True => true,
+ JsonValueKind.False => false,
+ _ => null
+ }
+ : null;
+
+ private static IReadOnlyList ReadStringArray(JsonElement parent, string name)
+ {
+ if (!parent.TryGetProperty(name, out var array) || array.ValueKind != JsonValueKind.Array)
+ {
+ return Array.Empty();
+ }
+
+ var result = new List(array.GetArrayLength());
+ foreach (var element in array.EnumerateArray())
+ {
+ if (element.ValueKind == JsonValueKind.String)
+ {
+ var value = element.GetString();
+ if (value != null)
+ {
+ result.Add(value);
+ }
+ }
+ }
+
+ return result;
+ }
+}
diff --git a/src/Daqifi.Core/Device/Capabilities/CapabilityIdentity.cs b/src/Daqifi.Core/Device/Capabilities/CapabilityIdentity.cs
new file mode 100644
index 00000000..33edcfb9
--- /dev/null
+++ b/src/Daqifi.Core/Device/Capabilities/CapabilityIdentity.cs
@@ -0,0 +1,32 @@
+namespace Daqifi.Core.Device.Capabilities;
+
+///
+/// The identity block of the capability document.
+///
+///
+/// Reported for diagnostics and cross-checking. daqifi-core does not derive
+/// or from it
+/// — those come from the protobuf status message, which every supported device sends whether or
+/// not it can answer the capability query, so keeping one source for them avoids two paths that
+/// can disagree.
+///
+public sealed class CapabilityIdentity
+{
+ /// Gets the vendor name, e.g. "DAQiFi".
+ public string? Vendor { get; init; }
+
+ /// Gets the product family, e.g. "Nyquist".
+ public string? Model { get; init; }
+
+ /// Gets the board variant, e.g. "NQ1".
+ public string? Variant { get; init; }
+
+ /// Gets the board serial number as a hexadecimal string.
+ public string? Serial { get; init; }
+
+ /// Gets the firmware revision string, e.g. "3.7.2".
+ public string? FirmwareRevision { get; init; }
+
+ /// Gets the hardware revision string, e.g. "2.0.0".
+ public string? HardwareRevision { get; init; }
+}
diff --git a/src/Daqifi.Core/Device/Capabilities/CapabilityRateModel.cs b/src/Daqifi.Core/Device/Capabilities/CapabilityRateModel.cs
new file mode 100644
index 00000000..6a7119a1
--- /dev/null
+++ b/src/Daqifi.Core/Device/Capabilities/CapabilityRateModel.cs
@@ -0,0 +1,116 @@
+using System;
+
+namespace Daqifi.Core.Device.Capabilities;
+
+///
+/// The device's published streaming-rate formula, so a client can preview the ceiling for a
+/// hypothetical channel selection without a round-trip per checkbox.
+///
+///
+/// The constants come from the firmware's own compile-time streaming budget. The result is an
+/// optimistic preview: it accounts for channel count and channel type only, so the rate the
+/// device will actually accept for a committed configuration
+/// () can be lower once the per-interface,
+/// per-encoding transport cap is folded in. Treat this as UI guidance and
+/// as authoritative.
+///
+public sealed class CapabilityRateModel
+{
+ /// Gets the formula as published by the device, for display and diagnostics.
+ public string? Formula { get; init; }
+
+ /// Gets the absolute sampling-ISR ceiling in Hz, or null when not stated.
+ public int? AbsoluteMaximumHz { get; init; }
+
+ ///
+ /// Gets the aggregate ceiling in Hz shared by all dedicated-converter ("simultaneous")
+ /// channels, or null when not stated.
+ ///
+ public int? Type1AggregateMaximumHz { get; init; }
+
+ /// Gets the per-tick sampling budget in Hz, or null when not stated.
+ public int? PerTickBudgetHz { get; init; }
+
+ ///
+ /// Gets the fixed per-tick cost, expressed in channel-equivalents, that the budget must cover
+ /// before any channel is sampled; or null when not stated.
+ ///
+ public int? PerTickOverhead { get; init; }
+
+ ///
+ /// Evaluates the device's formula for a hypothetical channel selection.
+ ///
+ ///
+ /// How many of the selected channels are dedicated-converter channels — count the
+ /// entries with
+ /// of and
+ /// set.
+ ///
+ ///
+ /// How many analog input channels are selected in total. Digital I/O and analog output do not
+ /// factor in — digital cost is amortized into , and analog output
+ /// is not streamed.
+ ///
+ /// The predicted ceiling in Hz, when the model can be evaluated.
+ ///
+ /// true when the document supplied enough constants to evaluate at least one term of the
+ /// formula; otherwise false, and the caller should fall back to
+ /// or the board-derived maximum.
+ ///
+ ///
+ /// Thrown when either count is negative, or when
+ /// exceeds — a selection that cannot exist, and one that
+ /// would otherwise silently produce a too-high ceiling.
+ ///
+ public bool TryComputeMaxRateHz(int simultaneousChannelCount, int totalChannelCount, out int maxRateHz)
+ {
+ if (simultaneousChannelCount < 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(simultaneousChannelCount), simultaneousChannelCount, "Channel count cannot be negative.");
+ }
+
+ if (totalChannelCount < 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(totalChannelCount), totalChannelCount, "Channel count cannot be negative.");
+ }
+
+ if (simultaneousChannelCount > totalChannelCount)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(simultaneousChannelCount),
+ simultaneousChannelCount,
+ "The simultaneous channel count cannot exceed the total channel count.");
+ }
+
+ int? ceiling = null;
+
+ if (AbsoluteMaximumHz > 0)
+ {
+ ceiling = AbsoluteMaximumHz.Value;
+ }
+
+ // Only a selection that actually includes dedicated-converter channels is constrained by
+ // their aggregate budget: dividing by a zero count would be a division by zero, and
+ // treating the term as 0 Hz would wrongly cap a muxed-only selection at nothing.
+ if (Type1AggregateMaximumHz > 0 && simultaneousChannelCount > 0)
+ {
+ var type1Term = Type1AggregateMaximumHz.Value / simultaneousChannelCount;
+ ceiling = ceiling.HasValue ? Math.Min(ceiling.Value, type1Term) : type1Term;
+ }
+
+ if (PerTickBudgetHz > 0 && PerTickOverhead >= 0)
+ {
+ var divisor = PerTickOverhead!.Value + totalChannelCount;
+ if (divisor > 0)
+ {
+ var budgetTerm = PerTickBudgetHz.Value / divisor;
+ ceiling = ceiling.HasValue ? Math.Min(ceiling.Value, budgetTerm) : budgetTerm;
+ }
+ }
+
+ maxRateHz = ceiling ?? 0;
+ return ceiling.HasValue;
+ }
+}
diff --git a/src/Daqifi.Core/Device/Capabilities/CapabilityStreaming.cs b/src/Daqifi.Core/Device/Capabilities/CapabilityStreaming.cs
new file mode 100644
index 00000000..4b41a051
--- /dev/null
+++ b/src/Daqifi.Core/Device/Capabilities/CapabilityStreaming.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Collections.Generic;
+
+namespace Daqifi.Core.Device.Capabilities;
+
+///
+/// The streaming block of the capability document.
+///
+///
+/// The three rate figures are three different contracts and are not interchangeable:
+///
+/// -
+/// is the hardware envelope — the sampling ISR's absolute
+/// ceiling, not achievable under real load. It is the board's true upper bound and is what
+/// is populated from.
+///
+/// -
+/// is guaranteed drop-free for any configuration. A
+/// client that always picks this rate is never surprised.
+///
+/// -
+/// is the device's authoritative cap for the channel set
+/// enabled right now, on the interface and encoding in use. It changes whenever the enabled
+/// set changes, so it is only as fresh as the last document read.
+///
+///
+///
+public sealed class CapabilityStreaming
+{
+ /// Gets the lowest sample rate in Hz the device accepts, or null when not stated.
+ public int? MinimumSampleRateHz { get; init; }
+
+ ///
+ /// Gets the absolute sampling-ISR ceiling in Hz, or null when not stated. The hardware
+ /// envelope, not a rate that is achievable with every channel set.
+ ///
+ public int? MaximumSampleRateHz { get; init; }
+
+ ///
+ /// Gets the rate in Hz the device guarantees drop-free regardless of channel selection,
+ /// interface, or encoding; or null when not stated.
+ ///
+ public int? ConservativeEnvelopeHz { get; init; }
+
+ ///
+ /// Gets the device's authoritative cap in Hz for the channel set enabled at the moment the
+ /// document was read, or null when not stated. The firmware reports 0 when no
+ /// channels are enabled; that 0 is preserved here rather than being treated as absent,
+ /// because it is a real answer ("nothing to stream") and not a missing field.
+ ///
+ public int? CurrentMaximumRateHz { get; init; }
+
+ ///
+ /// Gets how the device handles a start request above .
+ /// "error" — the firmware's behavior since v3.5.0 — means the start is rejected outright
+ /// with SCPI -222 and streaming does not begin; there is no silent clamping.
+ ///
+ public string? RateValidation { get; init; }
+
+ /// Gets the device's rate-prediction formula, or null when not stated.
+ public CapabilityRateModel? RateModel { get; init; }
+
+ /// Gets the stream encodings the device supports, e.g. pb, csv, json.
+ public IReadOnlyList Encodings { get; init; } = Array.Empty();
+
+ /// Gets the destinations the device can stream to, e.g. usb, wifi, sd.
+ public IReadOnlyList Transports { get; init; } = Array.Empty();
+}
diff --git a/src/Daqifi.Core/Device/DaqifiDevice.cs b/src/Daqifi.Core/Device/DaqifiDevice.cs
index 164d2121..70a954aa 100644
--- a/src/Daqifi.Core/Device/DaqifiDevice.cs
+++ b/src/Daqifi.Core/Device/DaqifiDevice.cs
@@ -3,6 +3,7 @@
using Daqifi.Core.Communication.Messages;
using Daqifi.Core.Communication.Producers;
using Daqifi.Core.Communication.Transport;
+using Daqifi.Core.Device.Capabilities;
using Daqifi.Core.Device.Protocol;
using Daqifi.Core.Firmware;
using Microsoft.Extensions.Logging;
@@ -1157,6 +1158,155 @@ public virtual async Task> DrainErrorQueueAsync(
return popped;
}
+ ///
+ /// Lowest capability-document schema version daqifi-core will parse.
+ ///
+ public const int MinimumCapabilityDocumentApiVersion = 1;
+
+ ///
+ /// Highest capability-document schema version daqifi-core has been written against.
+ ///
+ ///
+ /// The firmware bumps its schema version only on a breaking change — a field
+ /// renamed, removed, retyped, given new semantics, or the layout reshaped — while additive
+ /// fields ship without a bump. A version above this one is therefore a document whose
+ /// existing fields may no longer mean what this parser assumes, and trusting it could hand
+ /// a consumer a plausible but wrong number. Such a device keeps its board-derived
+ /// capabilities instead, which is the same safe outcome as a device that cannot answer at
+ /// all. Raise this constant together with the parser when adopting a newer schema.
+ ///
+ public const int MaximumCapabilityDocumentApiVersion = 2;
+
+ ///
+ /// Gap between the two capability queries sent in one exchange, so the firmware's SCPI
+ /// parser sees two commands rather than one write it has to split.
+ ///
+ private const int CapabilityQuerySpacingMs = 50;
+
+ /// Time allowed for the first line of the capability response.
+ private const int CapabilityDocumentResponseTimeoutMs = 3000;
+
+ ///
+ /// Inactivity window that ends capability-document collection. The document is a single
+ /// line of several kilobytes, so the window has to outlast the gaps within the
+ /// transfer — otherwise, on a device that echoes commands, the echo would start the
+ /// completion clock and cut the response off before its only useful line. Measured on the
+ /// bench NQ1 over USB CDC: 8 KB delivered in ~25 ms end to end, longest inter-chunk gap
+ /// 8 ms. 250 ms is an order of magnitude of headroom over that while keeping the cost to
+ /// the connect sequence small.
+ ///
+ private const int CapabilityDocumentCompletionTimeoutMs = 250;
+
+ ///
+ /// Reads the device's capability document (CONFigure:CAPabilities:JSON?) and
+ /// overlays it onto .
+ ///
+ ///
+ ///
+ /// Best-effort and non-destructive to what is already known. The read is skipped unless
+ /// reports (firmware
+ /// v3.5.0 and newer), and the document is only trusted after
+ /// CONFigure:CAPabilities:APIVersion? reports a schema version this parser
+ /// understands. Any other outcome — an unanswered query, an out-of-range schema version,
+ /// an unparseable reply — returns null and leaves the board-derived capabilities
+ /// exactly as they were.
+ ///
+ ///
+ /// The overlay is a merge: remains the
+ /// bootstrap and the fallback for every field the document does not state
+ /// (). calls this
+ /// once, after the device reports its board and firmware version. Call it again whenever a
+ /// fresh is needed — that figure is
+ /// computed from the channel set enabled at the moment of the read, so it goes stale as
+ /// soon as the enabled set changes.
+ ///
+ ///
+ /// This runs a text-mode exchange, which pauses the protobuf consumer for its duration.
+ /// Do not call it while streaming.
+ ///
+ ///
+ /// A cancellation token to observe while reading.
+ ///
+ /// The parsed document, which has already been applied to ; or
+ /// null when the device did not supply one this parser can trust.
+ ///
+ /// Thrown when the device is not connected.
+ /// Thrown when the operation is canceled.
+ public virtual async Task ReadCapabilityDocumentAsync(
+ CancellationToken cancellationToken = default)
+ {
+ if (!IsConnected)
+ {
+ throw new InvalidOperationException("Device is not connected.");
+ }
+
+ if (!Supports(DeviceFeature.CapabilityDocument))
+ {
+ SafeLog(() => _logger.LogDebug(
+ "[ReadCapabilityDocumentAsync] Skipped: the device does not report support for the capability document."));
+ return null;
+ }
+
+ // Both queries go out in one text exchange. Swapping the protobuf consumer out and back
+ // costs far more than either reply does — the reader thread has to time out of its
+ // blocking read first — so a second exchange would roughly double what this adds to
+ // the connect sequence, to fetch a document that measures 8 KB in ~25 ms. The version
+ // is still checked before the document is *trusted*, which is what the gate is for;
+ // asking for both up front only means a device on an unreadable schema transferred a
+ // document that is then discarded.
+ var lines = await ExecuteTextCommandAsync(
+ async token =>
+ {
+ Send(ScpiMessageProducer.GetCapabilitiesApiVersion);
+ await Task.Delay(CapabilityQuerySpacingMs, token).ConfigureAwait(false);
+ Send(ScpiMessageProducer.GetCapabilitiesJson);
+ },
+ responseTimeoutMs: CapabilityDocumentResponseTimeoutMs,
+ completionTimeoutMs: CapabilityDocumentCompletionTimeoutMs,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ if (!CapabilityDocumentParser.TryParseApiVersion(lines, out var apiVersion))
+ {
+ SafeLog(() => _logger.LogDebug(
+ "[ReadCapabilityDocumentAsync] The device did not report a capability schema version; keeping board-derived capabilities."));
+ return null;
+ }
+
+ if (apiVersion < MinimumCapabilityDocumentApiVersion || apiVersion > MaximumCapabilityDocumentApiVersion)
+ {
+ SafeLog(() => _logger.LogDebug(
+ "[ReadCapabilityDocumentAsync] Capability schema version {ApiVersion} is outside the supported range {MinVersion}..{MaxVersion}; keeping board-derived capabilities.",
+ apiVersion,
+ MinimumCapabilityDocumentApiVersion,
+ MaximumCapabilityDocumentApiVersion));
+ return null;
+ }
+
+ if (!CapabilityDocumentParser.TryParseLines(lines, out var document))
+ {
+ SafeLog(() => _logger.LogDebug(
+ "[ReadCapabilityDocumentAsync] The capability document did not parse; keeping board-derived capabilities."));
+ return null;
+ }
+
+ // The document body carries the same schema version the query reports — the firmware
+ // emits both from one macro. If they disagree, the two halves of this exchange did not
+ // come from one coherent response (a stale or interleaved line), so the version that
+ // was vetted above is not the version of the document in hand. Fail closed rather than
+ // apply a document nothing actually vouched for.
+ if (document.SchemaVersion != apiVersion)
+ {
+ SafeLog(() => _logger.LogDebug(
+ "[ReadCapabilityDocumentAsync] The document reports schema version {DocumentVersion} but the device reported {ApiVersion}; keeping board-derived capabilities.",
+ document.SchemaVersion,
+ apiVersion));
+ return null;
+ }
+
+ Metadata.ApplyCapabilityDocument(document);
+ return document;
+ }
+
///
/// Raises the event when a message is received from the device.
///
@@ -1346,6 +1496,16 @@ await WaitForChannelsPopulatedAsync(
effectiveChannelPopulationTimeout,
cancellationToken).ConfigureAwait(false);
+ // Ask the device to describe itself, now that it has reported its board and
+ // firmware version — Supports(CapabilityDocument) fails closed on an unknown
+ // firmware version, so an earlier read would skip on every device. This is its own
+ // SCPI round-trip rather than something folded into status processing: the
+ // document does not ride along in the protobuf status message, and a text exchange
+ // pauses the protobuf consumer, which is only safe at a quiescent point like this
+ // one. It runs before OnDeviceInitializingAsync so derived-class initialization
+ // already sees the device's own capabilities.
+ await TryReadCapabilityDocumentAsync(cancellationToken).ConfigureAwait(false);
+
// Run any derived-class initialization (e.g. routing the stream to USB) as part of
// this try/catch so a failure there leaves the device in a consistent terminal state
// rather than a falsely-ready device. _isInitialized is only set after it succeeds,
@@ -1385,6 +1545,33 @@ await WaitForChannelsPopulatedAsync(
/// A task representing the asynchronous operation.
protected virtual Task OnDeviceInitializingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ ///
+ /// Runs the capability-document read during initialization, absorbing any failure.
+ ///
+ ///
+ /// The document is an enrichment, never a requirement: a device that cannot supply one is
+ /// fully usable on its board-derived capabilities, so letting a failed read fail the whole
+ /// initialization would newly refuse devices that work today. Cancellation still
+ /// propagates — that is the caller's own request, not a device fault.
+ ///
+ private async Task TryReadCapabilityDocumentAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ await ReadCapabilityDocumentAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ SafeLog(() => _logger.LogDebug(
+ ex,
+ "[InitializeAsync] Reading the capability document failed; keeping board-derived capabilities."));
+ }
+ }
+
///
/// Sends GetDeviceInfo and waits for the device to report its channel
/// configuration via the event, re-sending the request
diff --git a/src/Daqifi.Core/Device/DeviceMetadata.cs b/src/Daqifi.Core/Device/DeviceMetadata.cs
index d01f797a..725623b3 100644
--- a/src/Daqifi.Core/Device/DeviceMetadata.cs
+++ b/src/Daqifi.Core/Device/DeviceMetadata.cs
@@ -1,3 +1,4 @@
+using Daqifi.Core.Device.Capabilities;
using Daqifi.Core.Device.Network;
namespace Daqifi.Core.Device;
@@ -35,6 +36,14 @@ public class DeviceMetadata
private DeviceCapabilities _capabilities = new();
private DeviceHealth _health = new();
+ ///
+ /// The board was last derived from, or null when it has
+ /// never been derived. Tracked separately from — which has a public
+ /// setter — so can tell "already built for this board" from
+ /// "the board was assigned but the capabilities were never built for it".
+ ///
+ private DeviceType? _capabilitiesBoard;
+
///
/// Gets or sets the device capabilities. Assigning null is coerced to a fresh instance so
/// the status-processing path (which populates channel counts here) can never dereference null.
@@ -45,6 +54,19 @@ public DeviceCapabilities Capabilities
set => _capabilities = value ?? new DeviceCapabilities();
}
+ ///
+ /// Gets the device's own capability document (CONFigure:CAPabilities:JSON?), or
+ /// null when it has not been read — the device predates the query, could not answer, or
+ /// has not run yet. Set through
+ /// .
+ ///
+ ///
+ /// Carries the full parsed document, including the figures
+ /// has no field for — the conservative streaming envelope, the cap for the currently enabled
+ /// channel set, and the device's rate-prediction model.
+ ///
+ public CapabilityDocument? CapabilityDocument { get; private set; }
+
///
/// Gets or sets the most recent device health telemetry (battery, board temperature,
/// power/device status) decoded from a status message. Updated on each status message,
@@ -112,6 +134,11 @@ public void CopyFrom(DeviceMetadata source)
HardwareRevision = source.HardwareRevision;
DeviceType = source.DeviceType;
Capabilities = source.Capabilities?.Clone() ?? new DeviceCapabilities();
+ _capabilitiesBoard = source._capabilitiesBoard;
+ // The document is immutable once parsed, so the reference is safe to share — and copying
+ // it matters: without it, the target's next status message would rebuild Capabilities from
+ // the board table with nothing to re-overlay, silently discarding the device's own values.
+ CapabilityDocument = source.CapabilityDocument;
Health = source.Health?.Clone() ?? new DeviceHealth();
IpAddress = source.IpAddress;
MacAddress = source.MacAddress;
@@ -123,6 +150,24 @@ public void CopyFrom(DeviceMetadata source)
WifiInfrastructureMode = source.WifiInfrastructureMode;
}
+ ///
+ /// Applies a capability document read from the device, overlaying it onto the board-derived
+ /// and retaining it for later re-application.
+ ///
+ ///
+ /// The overlay is re-applied on every subsequent , so a status
+ /// message cannot revert the device's own values to the board table's.
+ ///
+ /// The parsed capability document.
+ /// is null.
+ public void ApplyCapabilityDocument(CapabilityDocument document)
+ {
+ ArgumentNullException.ThrowIfNull(document);
+
+ CapabilityDocument = document;
+ document.MergeInto(Capabilities);
+ }
+
///
/// Updates the device metadata from a protobuf message.
///
@@ -132,8 +177,39 @@ public void UpdateFromProtobuf(DaqifiOutMessage message)
if (!string.IsNullOrWhiteSpace(message.DevicePn))
{
PartNumber = message.DevicePn;
- DeviceType = DeviceTypeDetector.DetectFromPartNumber(message.DevicePn);
- Capabilities = DeviceCapabilities.FromDeviceType(DeviceType);
+
+ // Rebuild the board-derived capabilities only when they are not already built for this
+ // board. Status messages repeat the part number, and rebuilding on each one discarded
+ // everything learned since — the channel counts that a status message with no port
+ // fields does not restore, and any capability-document overlay.
+ var detectedDeviceType = DeviceTypeDetector.DetectFromPartNumber(message.DevicePn);
+ DeviceType = detectedDeviceType;
+ if (_capabilitiesBoard != detectedDeviceType)
+ {
+ // Drop the retained document only when this object moves between two *known*
+ // boards — a reconnect to a different unit through the same instance. The document
+ // describes the board it was read from, so re-applying it there would overlay the
+ // previous device's flags, channel counts and rate ceiling onto the new one. The
+ // overlay is durable across status messages by design; it is not durable across a
+ // board change.
+ //
+ // A transition involving Unknown is not a board change. Per ADR 0001, Unknown means
+ // "not yet known", not "a different board": before the first status message there
+ // is no previous board at all, and an unrecognized part number says nothing about
+ // the device having been swapped. In both directions the document was still read
+ // from this device, and discarding it would regress capabilities — notably
+ // MaxSamplingRate back to the stale board-table ceiling — for no gain.
+ var previousBoard = _capabilitiesBoard;
+ if (previousBoard is not null
+ && previousBoard != DeviceType.Unknown
+ && detectedDeviceType != DeviceType.Unknown)
+ {
+ CapabilityDocument = null;
+ }
+
+ Capabilities = DeviceCapabilities.FromDeviceType(detectedDeviceType);
+ _capabilitiesBoard = detectedDeviceType;
+ }
}
if (message.DeviceSn != 0)
@@ -209,6 +285,11 @@ public void UpdateFromProtobuf(DaqifiOutMessage message)
Capabilities.DigitalChannels = (int)message.DigitalPortNum;
}
+ // Re-apply the device's own capability document last, so it stays the authority over both
+ // the board table and the status message's port counts for the fields it states. This is
+ // what makes the merge durable: without it a status frame would win by arriving later.
+ CapabilityDocument?.MergeInto(Capabilities);
+
// Update health telemetry. proto3 scalars have no explicit presence, so a value of 0
// is indistinguishable from "not reported"; guard on non-zero (consistent with the
// other fields above) so a partial status message never clobbers a known reading.