From 06b529aa1675533968b22c07b26125cb3fcbc9d2 Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Wed, 29 Jul 2026 08:54:50 -0600 Subject: [PATCH 1/6] feat(device): read the live capability document and overlay DeviceCapabilities (closes #390) Firmware v3.5.0+ can describe itself via CONFigure:CAPabilities:JSON? and CONFigure:CAPabilities:APIVersion?. Core now reads that document during initialization and merges it onto the board-derived capabilities. - ScpiMessageProducer: GetCapabilitiesApiVersion / GetCapabilitiesJson. - Daqifi.Core.Device.Capabilities: CapabilityDocument + tolerant parser, plus the device's own streaming rate model (the figure desktop #118 needs and the static table cannot express). - DaqifiDevice.ReadCapabilityDocumentAsync(): gated on Supports(DeviceFeature.CapabilityDocument) and on a schema version in 1..2. Called once from InitializeAsync after the board and firmware version are known; failures are absorbed so a device that cannot answer is unaffected. - DeviceMetadata: keeps the document, and UpdateFromProtobuf now rebuilds the board-derived capabilities only when the board changes, re-applying the overlay last. It previously replaced them on every status frame, discarding both the overlay and the channel counts. - ADR 0001 Decision 2 item 4 updated from growth path to built. Co-Authored-By: Claude Opus 5 --- docs/adr/0001-firmware-feature-gating.md | 77 +++- .../Producers/ScpiMessageProducerTests.cs | 22 + .../CapabilityDocumentMergeTests.cs | 157 +++++++ .../CapabilityDocumentParserTests.cs | 309 ++++++++++++++ .../Capabilities/CapabilityDocumentSamples.cs | 24 ++ .../Capabilities/CapabilityRateModelTests.cs | 117 +++++ .../DaqifiDeviceCapabilityDocumentTests.cs | 223 ++++++++++ .../Device/DeviceMetadataTests.cs | 85 ++++ .../Producers/ScpiMessageProducer.cs | 31 ++ .../Device/Capabilities/CapabilityChannel.cs | 76 ++++ .../Capabilities/CapabilityChannelKind.cs | 25 ++ .../Device/Capabilities/CapabilityDocument.cs | 169 ++++++++ .../Capabilities/CapabilityDocumentParser.cs | 400 ++++++++++++++++++ .../Device/Capabilities/CapabilityIdentity.cs | 32 ++ .../Capabilities/CapabilityRateModel.cs | 116 +++++ .../Capabilities/CapabilityStreaming.cs | 68 +++ src/Daqifi.Core/Device/DaqifiDevice.cs | 160 +++++++ src/Daqifi.Core/Device/DeviceMetadata.cs | 54 ++- 18 files changed, 2129 insertions(+), 16 deletions(-) create mode 100644 src/Daqifi.Core.Tests/Device/Capabilities/CapabilityDocumentMergeTests.cs create mode 100644 src/Daqifi.Core.Tests/Device/Capabilities/CapabilityDocumentParserTests.cs create mode 100644 src/Daqifi.Core.Tests/Device/Capabilities/CapabilityDocumentSamples.cs create mode 100644 src/Daqifi.Core.Tests/Device/Capabilities/CapabilityRateModelTests.cs create mode 100644 src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs create mode 100644 src/Daqifi.Core/Device/Capabilities/CapabilityChannel.cs create mode 100644 src/Daqifi.Core/Device/Capabilities/CapabilityChannelKind.cs create mode 100644 src/Daqifi.Core/Device/Capabilities/CapabilityDocument.cs create mode 100644 src/Daqifi.Core/Device/Capabilities/CapabilityDocumentParser.cs create mode 100644 src/Daqifi.Core/Device/Capabilities/CapabilityIdentity.cs create mode 100644 src/Daqifi.Core/Device/Capabilities/CapabilityRateModel.cs create mode 100644 src/Daqifi.Core/Device/Capabilities/CapabilityStreaming.cs diff --git a/docs/adr/0001-firmware-feature-gating.md b/docs/adr/0001-firmware-feature-gating.md index dba8d7a9..f66cf926 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,50 @@ 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. +- **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. + +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 +349,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..7150c72d --- /dev/null +++ b/src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs @@ -0,0 +1,223 @@ +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_DoesNotRequestTheDocument() + { + var device = CreateSupportedDevice(); + device.Responses[ApiVersionCommand] = ["**ERROR: -113, \"Undefined header\""]; + device.Responses[DocumentCommand] = [CapabilityDocumentSamples.Nyquist1Firmware372]; + + Assert.Null(await device.ReadCapabilityDocumentAsync()); + Assert.Equal(new[] { ApiVersionCommand }, device.SentCommands); + 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.Equal(new[] { ApiVersionCommand }, device.SentCommands); + Assert.Null(device.Metadata.CapabilityDocument); + } + + [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.Equal(new[] { ApiVersionCommand }, device.SentCommands); + Assert.Equal(1000, 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 Task> ExecuteTextCommandAsync( + Action setupAction, + int responseTimeoutMs = 1000, + int completionTimeoutMs = 250, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var before = SentCommands.Count; + setupAction(); + + var lines = SentCommands + .Skip(before) + .SelectMany(command => Responses.TryGetValue(command, out var response) + ? response + : Array.Empty()) + .ToList(); + + return Task.FromResult>(lines); + } + } +} diff --git a/src/Daqifi.Core.Tests/Device/DeviceMetadataTests.cs b/src/Daqifi.Core.Tests/Device/DeviceMetadataTests.cs index 058d13f9..22661e87 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,89 @@ 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 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 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..bf25a7ad 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. /// 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 29a3bf30..307e3448 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; @@ -1087,6 +1088,128 @@ 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; + + /// Time allowed for the one-line CONFigure:CAPabilities:APIVersion? reply. + private const int CapabilityApiVersionResponseTimeoutMs = 1000; + + /// Time allowed for the first line of the capability document. + private const int CapabilityDocumentResponseTimeoutMs = 3000; + + /// + /// Inactivity window that ends capability-document collection. Generous because the + /// document is a single line of several kilobytes: on a device that echoes commands, the + /// echo arrives first and starts the completion clock while the document is still in + /// flight, so a short window would cut the response off before its only useful line. + /// + private const int CapabilityDocumentCompletionTimeoutMs = 1500; + + /// + /// 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; + } + + var versionLines = await ExecuteTextCommandAsync( + () => Send(ScpiMessageProducer.GetCapabilitiesApiVersion), + responseTimeoutMs: CapabilityApiVersionResponseTimeoutMs, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (!CapabilityDocumentParser.TryParseApiVersion(versionLines, 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; + } + + var documentLines = await ExecuteTextCommandAsync( + () => Send(ScpiMessageProducer.GetCapabilitiesJson), + responseTimeoutMs: CapabilityDocumentResponseTimeoutMs, + completionTimeoutMs: CapabilityDocumentCompletionTimeoutMs, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (!CapabilityDocumentParser.TryParseLines(documentLines, out var document)) + { + SafeLog(() => _logger.LogDebug( + "[ReadCapabilityDocumentAsync] The capability document did not parse; keeping board-derived capabilities.")); + return null; + } + + Metadata.ApplyCapabilityDocument(document); + return document; + } + /// /// Raises the event when a message is received from the device. /// @@ -1276,6 +1399,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, @@ -1315,6 +1448,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..f050a87d 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; @@ -45,6 +46,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 +126,10 @@ public void CopyFrom(DeviceMetadata source) HardwareRevision = source.HardwareRevision; DeviceType = source.DeviceType; Capabilities = source.Capabilities?.Clone() ?? new DeviceCapabilities(); + // 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 +141,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 +168,17 @@ 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 the board itself changed. Status + // messages repeat the part number, and rebuilding on each one discarded everything + // learned since — the channel counts a status message with no port fields would not + // restore, and any capability-document overlay. + var detectedDeviceType = DeviceTypeDetector.DetectFromPartNumber(message.DevicePn); + if (detectedDeviceType != DeviceType) + { + DeviceType = detectedDeviceType; + Capabilities = DeviceCapabilities.FromDeviceType(DeviceType); + } } if (message.DeviceSn != 0) @@ -209,6 +254,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. From 28ba6d367a96a6af12a406dd4f629eda99b24ffc Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Wed, 29 Jul 2026 08:57:33 -0600 Subject: [PATCH 2/6] refactor(device): track the board Capabilities were derived from explicitly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review follow-up. Comparing the detected board against the public DeviceType property left a hole: a consumer that assigns DeviceType itself and then receives a matching status message would never get board-derived capabilities built at all. Track the board the current Capabilities were derived from in its own field instead, so "already built for this board" and "the board was assigned but nothing was built" are distinguishable. Also drops the stale "1-1000" range from StartStreaming's doc comment — the usable range is board- and configuration-dependent, and the literal is now actively misleading with MaxSamplingRate populated from the device. Co-Authored-By: Claude Opus 5 --- .../Device/DeviceMetadataTests.cs | 14 +++++++++++ .../Producers/ScpiMessageProducer.cs | 12 +++++++--- src/Daqifi.Core/Device/DeviceMetadata.cs | 24 +++++++++++++------ 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/Daqifi.Core.Tests/Device/DeviceMetadataTests.cs b/src/Daqifi.Core.Tests/Device/DeviceMetadataTests.cs index 22661e87..e1f729cf 100644 --- a/src/Daqifi.Core.Tests/Device/DeviceMetadataTests.cs +++ b/src/Daqifi.Core.Tests/Device/DeviceMetadataTests.cs @@ -86,6 +86,20 @@ public void UpdateFromProtobuf_ChangedPartNumber_RebuildsBoardDerivedCapabilitie 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() { diff --git a/src/Daqifi.Core/Communication/Producers/ScpiMessageProducer.cs b/src/Daqifi.Core/Communication/Producers/ScpiMessageProducer.cs index bf25a7ad..066e3c51 100644 --- a/src/Daqifi.Core/Communication/Producers/ScpiMessageProducer.cs +++ b/src/Daqifi.Core/Communication/Producers/ScpiMessageProducer.cs @@ -393,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). - /// - /// Starts streaming data from enabled channels at the specified frequency. + /// The streaming frequency in Hz. + /// + /// 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/DeviceMetadata.cs b/src/Daqifi.Core/Device/DeviceMetadata.cs index f050a87d..dd8e2a3b 100644 --- a/src/Daqifi.Core/Device/DeviceMetadata.cs +++ b/src/Daqifi.Core/Device/DeviceMetadata.cs @@ -36,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. @@ -126,6 +134,7 @@ 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. @@ -169,15 +178,16 @@ public void UpdateFromProtobuf(DaqifiOutMessage message) { PartNumber = message.DevicePn; - // Rebuild the board-derived capabilities only when the board itself changed. Status - // messages repeat the part number, and rebuilding on each one discarded everything - // learned since — the channel counts a status message with no port fields would not - // restore, and any capability-document overlay. + // 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); - if (detectedDeviceType != DeviceType) + DeviceType = detectedDeviceType; + if (_capabilitiesBoard != detectedDeviceType) { - DeviceType = detectedDeviceType; - Capabilities = DeviceCapabilities.FromDeviceType(DeviceType); + Capabilities = DeviceCapabilities.FromDeviceType(detectedDeviceType); + _capabilitiesBoard = detectedDeviceType; } } From 6d70c6197ef82da3fee6679562cd772cfe42c751 Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Wed, 29 Jul 2026 09:21:27 -0600 Subject: [PATCH 3/6] perf(device): read both capability queries in one text exchange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bench measurement, not speculation. The naive shape — one text exchange per query, with a completion window sized for a worst case I had not measured — added 3.64 s to connect+initialize on the bench NQ1 (3.16 s -> 6.80 s). Two fixes, both measured: - The completion window was 1500 ms. The document actually arrives in ~25 ms end to end with a longest inter-chunk gap of 8 ms, so 250 ms already has an order of magnitude of headroom. Saves ~1.25 s. - Send both queries in a single exchange. Swapping the protobuf consumer out and back dominates the cost — the reader thread has to time out of its blocking read first — so a second exchange bought nothing but latency. The schema version still gates whether the document is trusted, which is what the gate is for. Saves ~1.0 s. Connect+initialize is now ~4.29 s against a ~3.16 s baseline. Co-Authored-By: Claude Opus 5 --- docs/adr/0001-firmware-feature-gating.md | 8 ++- .../DaqifiDeviceCapabilityDocumentTests.cs | 35 +++++++++---- src/Daqifi.Core/Device/DaqifiDevice.cs | 51 ++++++++++++------- 3 files changed, 64 insertions(+), 30 deletions(-) diff --git a/docs/adr/0001-firmware-feature-gating.md b/docs/adr/0001-firmware-feature-gating.md index f66cf926..a06773ac 100644 --- a/docs/adr/0001-firmware-feature-gating.md +++ b/docs/adr/0001-firmware-feature-gating.md @@ -284,7 +284,11 @@ turns the reply into a 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. + 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 @@ -302,7 +306,7 @@ turns the reply into a 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. + 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, diff --git a/src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs b/src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs index 7150c72d..b541a631 100644 --- a/src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs +++ b/src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs @@ -86,14 +86,16 @@ public async Task ReadCapabilityDocumentAsync_WithNoReportedFirmwareVersion_IsSk } [Fact] - public async Task ReadCapabilityDocumentAsync_WhenApiVersionQueryFails_DoesNotRequestTheDocument() + 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.Equal(new[] { ApiVersionCommand }, device.SentCommands); + Assert.Null(device.Metadata.CapabilityDocument); Assert.Equal(1000, device.Metadata.Capabilities.MaxSamplingRate); } @@ -108,8 +110,8 @@ public async Task ReadCapabilityDocumentAsync_ApiVersionBelowMinimum_DoesNotTrus device.Responses[DocumentCommand] = [CapabilityDocumentSamples.Nyquist1Firmware372]; Assert.Null(await device.ReadCapabilityDocumentAsync()); - Assert.Equal(new[] { ApiVersionCommand }, device.SentCommands); Assert.Null(device.Metadata.CapabilityDocument); + Assert.Equal(1000, device.Metadata.Capabilities.MaxSamplingRate); } [Fact] @@ -124,7 +126,7 @@ public async Task ReadCapabilityDocumentAsync_ApiVersionAboveMaximum_DoesNotTrus device.Responses[DocumentCommand] = [CapabilityDocumentSamples.Nyquist1Firmware372]; Assert.Null(await device.ReadCapabilityDocumentAsync()); - Assert.Equal(new[] { ApiVersionCommand }, device.SentCommands); + Assert.Null(device.Metadata.CapabilityDocument); Assert.Equal(1000, device.Metadata.Capabilities.MaxSamplingRate); } @@ -209,15 +211,30 @@ protected override Task> ExecuteTextCommandAsync( cancellationToken.ThrowIfCancellationRequested(); var before = SentCommands.Count; setupAction(); + return Task.FromResult(ResponsesSince(before)); + } - var lines = SentCommands - .Skip(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(); - - return Task.FromResult>(lines); - } } } diff --git a/src/Daqifi.Core/Device/DaqifiDevice.cs b/src/Daqifi.Core/Device/DaqifiDevice.cs index 307e3448..0127afe1 100644 --- a/src/Daqifi.Core/Device/DaqifiDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiDevice.cs @@ -1107,19 +1107,25 @@ public virtual async Task> DrainErrorQueueAsync( /// public const int MaximumCapabilityDocumentApiVersion = 2; - /// Time allowed for the one-line CONFigure:CAPabilities:APIVersion? reply. - private const int CapabilityApiVersionResponseTimeoutMs = 1000; + /// + /// 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 document. + /// Time allowed for the first line of the capability response. private const int CapabilityDocumentResponseTimeoutMs = 3000; /// - /// Inactivity window that ends capability-document collection. Generous because the - /// document is a single line of several kilobytes: on a device that echoes commands, the - /// echo arrives first and starts the completion clock while the document is still in - /// flight, so a short window would cut the response off before its only useful line. + /// 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 = 1500; + private const int CapabilityDocumentCompletionTimeoutMs = 250; /// /// Reads the device's capability document (CONFigure:CAPabilities:JSON?) and @@ -1171,12 +1177,25 @@ public virtual async Task> DrainErrorQueueAsync( return null; } - var versionLines = await ExecuteTextCommandAsync( - () => Send(ScpiMessageProducer.GetCapabilitiesApiVersion), - responseTimeoutMs: CapabilityApiVersionResponseTimeoutMs, + // 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(versionLines, out var apiVersion)) + if (!CapabilityDocumentParser.TryParseApiVersion(lines, out var apiVersion)) { SafeLog(() => _logger.LogDebug( "[ReadCapabilityDocumentAsync] The device did not report a capability schema version; keeping board-derived capabilities.")); @@ -1193,13 +1212,7 @@ public virtual async Task> DrainErrorQueueAsync( return null; } - var documentLines = await ExecuteTextCommandAsync( - () => Send(ScpiMessageProducer.GetCapabilitiesJson), - responseTimeoutMs: CapabilityDocumentResponseTimeoutMs, - completionTimeoutMs: CapabilityDocumentCompletionTimeoutMs, - cancellationToken: cancellationToken).ConfigureAwait(false); - - if (!CapabilityDocumentParser.TryParseLines(documentLines, out var document)) + if (!CapabilityDocumentParser.TryParseLines(lines, out var document)) { SafeLog(() => _logger.LogDebug( "[ReadCapabilityDocumentAsync] The capability document did not parse; keeping board-derived capabilities.")); From ff5c3be8165caad60158750d66ad49329c08a67e Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Wed, 29 Jul 2026 09:40:53 -0600 Subject: [PATCH 4/6] fix(device): fail closed on a stale capability document (Qodo review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two valid findings from review, both about trusting a document that nothing vouched for: - The retained document survived a board change. UpdateFromProtobuf rebuilt the board-derived capabilities for the new board and then re-merged the previous device's document over them — wrong flags, channel counts and rate ceiling on a reconnect to a different unit through the same instance. It is now dropped on a board transition. Deriving for the first time is not a transition: there is no previous board, and a document read before the board was known was still read from this device. - The document body's schema_version was never compared against the version the APIVersion? query reported. The firmware emits both from one macro, so a disagreement means the two halves of the exchange did not come from one coherent response — which matters more now that both queries share a single exchange. Mismatch is now rejected. Co-Authored-By: Claude Opus 5 --- .../DaqifiDeviceCapabilityDocumentTests.cs | 35 +++++++++++++++++++ .../Device/DeviceMetadataTests.cs | 22 ++++++++++++ src/Daqifi.Core/Device/DaqifiDevice.cs | 14 ++++++++ src/Daqifi.Core/Device/DeviceMetadata.cs | 12 +++++++ 4 files changed, 83 insertions(+) diff --git a/src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs b/src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs index b541a631..656b6fe3 100644 --- a/src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs +++ b/src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs @@ -130,6 +130,41 @@ public async Task ReadCapabilityDocumentAsync_ApiVersionAboveMaximum_DoesNotTrus 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() { diff --git a/src/Daqifi.Core.Tests/Device/DeviceMetadataTests.cs b/src/Daqifi.Core.Tests/Device/DeviceMetadataTests.cs index e1f729cf..5d567f61 100644 --- a/src/Daqifi.Core.Tests/Device/DeviceMetadataTests.cs +++ b/src/Daqifi.Core.Tests/Device/DeviceMetadataTests.cs @@ -121,6 +121,28 @@ public void ApplyCapabilityDocument_OverlaysDocumentAndSurvivesLaterStatusMessag 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); + } + [Fact] public void ApplyCapabilityDocument_NullDocument_Throws() { diff --git a/src/Daqifi.Core/Device/DaqifiDevice.cs b/src/Daqifi.Core/Device/DaqifiDevice.cs index 0127afe1..86c8b242 100644 --- a/src/Daqifi.Core/Device/DaqifiDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiDevice.cs @@ -1219,6 +1219,20 @@ public virtual async Task> DrainErrorQueueAsync( 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; } diff --git a/src/Daqifi.Core/Device/DeviceMetadata.cs b/src/Daqifi.Core/Device/DeviceMetadata.cs index dd8e2a3b..b67ac8b5 100644 --- a/src/Daqifi.Core/Device/DeviceMetadata.cs +++ b/src/Daqifi.Core/Device/DeviceMetadata.cs @@ -186,6 +186,18 @@ public void UpdateFromProtobuf(DaqifiOutMessage message) DeviceType = detectedDeviceType; if (_capabilitiesBoard != detectedDeviceType) { + // Drop the retained document when the board actually *changes* — a reconnect to a + // different unit through the same instance — because it describes the board it was + // read from, and re-applying it 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. Deriving for the + // first time is not a change: there is no previous board, and a document read + // before the board was known was still read from this device. + if (_capabilitiesBoard.HasValue) + { + CapabilityDocument = null; + } + Capabilities = DeviceCapabilities.FromDeviceType(detectedDeviceType); _capabilitiesBoard = detectedDeviceType; } From 5b3fce0686c9262bdfa4e7ae92028624d565a55d Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Wed, 29 Jul 2026 09:50:25 -0600 Subject: [PATCH 5/6] fix(device): treat Unknown as not-yet-known when invalidating the overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qodo round 2. Clearing the retained capability document on any board change also fired on transitions involving DeviceType.Unknown, which an unrecognized part number produces. That made Unknown -> Nq1 destructive: a document read from this very device (Supports() skips the board axis while the board is Unknown, so the read does happen) was discarded, reverting MaxSamplingRate to the stale 1 kHz board-table ceiling. Per ADR 0001, Unknown means "not yet known", not "a different board" — in neither direction does it evidence a swapped device. The document is now dropped only on a transition between two known boards. Co-Authored-By: Claude Opus 5 --- .../Device/DeviceMetadataTests.cs | 24 ++++++++++++++++++ src/Daqifi.Core/Device/DeviceMetadata.cs | 25 +++++++++++++------ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/Daqifi.Core.Tests/Device/DeviceMetadataTests.cs b/src/Daqifi.Core.Tests/Device/DeviceMetadataTests.cs index 5d567f61..d686627f 100644 --- a/src/Daqifi.Core.Tests/Device/DeviceMetadataTests.cs +++ b/src/Daqifi.Core.Tests/Device/DeviceMetadataTests.cs @@ -143,6 +143,30 @@ public void UpdateFromProtobuf_ChangedPartNumber_DiscardsTheOldBoardsCapabilityD 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() { diff --git a/src/Daqifi.Core/Device/DeviceMetadata.cs b/src/Daqifi.Core/Device/DeviceMetadata.cs index b67ac8b5..725623b3 100644 --- a/src/Daqifi.Core/Device/DeviceMetadata.cs +++ b/src/Daqifi.Core/Device/DeviceMetadata.cs @@ -186,14 +186,23 @@ public void UpdateFromProtobuf(DaqifiOutMessage message) DeviceType = detectedDeviceType; if (_capabilitiesBoard != detectedDeviceType) { - // Drop the retained document when the board actually *changes* — a reconnect to a - // different unit through the same instance — because it describes the board it was - // read from, and re-applying it 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. Deriving for the - // first time is not a change: there is no previous board, and a document read - // before the board was known was still read from this device. - if (_capabilitiesBoard.HasValue) + // 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; } From 90cf1d29ca11c3973fbe0b3938e554b6ce40c451 Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Wed, 29 Jul 2026 11:48:54 -0600 Subject: [PATCH 6/6] test: widen the capability-document fake for the prepare-phase seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #406 added an optional prepareAsync parameter to the virtual ExecuteTextCommandAsync so a subclass cannot silently stop intercepting SD operations. C# requires an exact parameter-list match to override, so this fake failed with CS0115 once main landed — the compile error the seam is designed to produce. Widen the override and honor the prepare phase the way the real device does (it runs first, before anything the exchange sends), matching the fakes updated in #406. Co-Authored-By: Claude Opus 5 --- .../DaqifiDeviceCapabilityDocumentTests.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs b/src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs index 656b6fe3..5d46a7d5 100644 --- a/src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs +++ b/src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs @@ -237,16 +237,25 @@ public override void Send(IOutboundMessage message) } } - protected override Task> ExecuteTextCommandAsync( + protected override async Task> ExecuteTextCommandAsync( Action setupAction, int responseTimeoutMs = 1000, int completionTimeoutMs = 250, - CancellationToken cancellationToken = default) + 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 Task.FromResult(ResponsesSince(before)); + return ResponsesSince(before); } protected override async Task> ExecuteTextCommandAsync(