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