From 03f4cf4891f0dc926018b55469c52b1f9b122c3a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 10:11:46 +0000 Subject: [PATCH 1/4] docs: DEVICE_INTERFACES Status/dedupe + ADR 0001 table tense Leave #740's Quick Start typed API, CI-OS sentence, and ADR follow-up checkboxes alone. This is the leftover stale copy: six-value Status, README clones replaced with links, Features bullets dropped, Supports recipe added, and ADR 0001 amended to present tense for the shipped table. Co-authored-by: Tyler Kron --- docs/DEVICE_INTERFACES.md | 234 ++++++++++------------- docs/adr/0001-firmware-feature-gating.md | 21 +- 2 files changed, 112 insertions(+), 143 deletions(-) diff --git a/docs/DEVICE_INTERFACES.md b/docs/DEVICE_INTERFACES.md index aeae519f..463d9d41 100644 --- a/docs/DEVICE_INTERFACES.md +++ b/docs/DEVICE_INTERFACES.md @@ -6,28 +6,14 @@ This document provides examples and usage guidance for the DAQiFi device interfa The device interfaces provide a consistent API for discovering, connecting to, and communicating with DAQiFi hardware devices. They abstract away hardware implementation details and provide a clean interface for application developers. -## Quick Start +## Start here -The simplest way to connect to a DAQiFi device is using the `DaqifiDeviceFactory`: +Connecting, enabling a channel and streaming is a handful of lines — the root README's +[See it in 30 seconds](../README.md#see-it-in-30-seconds) is the copy-paste version, and it +is kept current there rather than in two places. -```csharp -using Daqifi.Core.Channel; -using Daqifi.Core.Device; - -// Connect to a device (handles transport, connection, and initialization) -await using var device = await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760); - -var ai0 = device.GetChannelsSnapshot().First(c => c.Type == ChannelType.Analog && c.ChannelNumber == 0); -ai0.SampleReceived += (_, e) => Console.WriteLine($"{e.Sample.Timestamp}: {e.Sample.Value} V"); - -device.EnableChannel(ai0); -device.StreamingFrequency = 100; -device.StartStreaming(); - -await Task.Delay(TimeSpan.FromSeconds(10)); - -device.StopStreaming(); -``` +This document is the layer under it: the interfaces themselves, discovery beyond one call, +reconnect, feature gating, thread-safety rules, and diagnostics. ## Core Interfaces @@ -38,7 +24,9 @@ The base interface for all DAQiFi devices, providing fundamental connection and - `Name` - Device identifier - `IpAddress` - Network address (for WiFi devices) - `IsConnected` - Connection status -- `Status` - Detailed connection status (Disconnected, Connecting, Connected, Lost) +- `Status` - Detailed connection status (`Disconnected`, `Connecting`, `Connected`, `Lost`; + plus `Retrying` / `Failed` once reconnection is enabled — see + [Reconnecting automatically after a drop](#reconnecting-automatically-after-a-drop)) - `Connect()` / `Disconnect()` - Blocking connection management - `ConnectAsync(CancellationToken)` / `DisconnectAsync(CancellationToken)` - Non-blocking, cancellable connection management. These are genuine interface members, not default-interface-method shims over @@ -183,26 +171,10 @@ Pre-configured presets: ### Device Discovery and Connection -```csharp -using Daqifi.Core.Device; -using Daqifi.Core.Device.Discovery; - -// Discover devices on the network -using var finder = new WiFiDeviceFinder(); -var devices = await finder.DiscoverAsync(TimeSpan.FromSeconds(5)); - -foreach (var deviceInfo in devices) -{ - Console.WriteLine($"Found: {deviceInfo.Name} at {deviceInfo.IPAddress}:{deviceInfo.Port}"); -} - -// Connect to the first discovered device -if (devices.Any()) -{ - await using var device = await DaqifiDeviceFactory.ConnectFromDeviceInfoAsync(devices.First()); - // Device is ready to use -} -``` +Find-and-connect recipes (WiFi broadcast, serial, mDNS, `AllTransportsDeviceFinder`) live +in the root README under [Device discovery](../README.md#device-discovery). The sections +below are what that page does not cover: USB `LocationKey` correlation, continuous +discovery, and the deeper network-finder notes. ### USB Physical-Location Correlation @@ -238,17 +210,15 @@ resolve to `null`, same as `IUsbPortDescriptorProvider`'s cross-platform fallbac > platform-independent parsing/fallback logic; it is not a hardware confirmation. Confirm both > on a Windows bench with real hardware before relying on this for anything safety-critical. -### Discover Across All Transports (Recommended) +### Discover Across All Transports -To "find any DAQiFi on WiFi or USB" in one call, use `AllTransportsDeviceFinder` — it runs the -per-transport finders concurrently and returns a single deduplicated set. Because it is itself an -`IDeviceFinder`, wrapping it in a `ContinuousDeviceFinder` (below) gives deduplicated *continuous* -discovery across every transport for free. +The README's [Device discovery](../README.md#device-discovery) recipe constructs +`AllTransportsDeviceFinder` from the individual finders. Two helpers live only here: ```csharp using Daqifi.Core.Device.Discovery; -// One-shot across WiFi + serial: +// One-shot across the default WiFi + serial set (no mDNS): using var finder = AllTransportsDeviceFinder.CreateDefault(); var devices = await finder.DiscoverAsync(TimeSpan.FromSeconds(3)); @@ -258,69 +228,49 @@ var device = await DaqifiDeviceFactory.DiscoverAndConnectAsync( timeout: TimeSpan.FromSeconds(5)); ``` -A transport finder that fails (e.g. WiFi discovery with no network) is logged and skipped, so the -other transports still return. Deduplication reuses `ContinuousDeviceFinder`'s per-transport -identity, so the same physical unit reachable over both WiFi and USB appears as two connection -options; pass a custom `identitySelector` (e.g. by serial number) to collapse them. - -### Network Discovery: UDP Broadcast and mDNS - -There are two ways to find a device over the network, and on a real home network you want both. - -`WiFiDeviceFinder` sends the legacy `DAQiFi?\r\n` query to the subnet broadcast address on UDP -30303. It works, and it is the only path for devices on older firmware — but subnet-directed -broadcast is unreliable the moment more than one access point is involved. On a single-SSID, -two-AP network with the host on 5 GHz at one AP and the device on 2.4 GHz at the other, enough -broadcast frames are dropped crossing the AP boundary that ARP itself fails to resolve; discovery -then returns zero devices while the device is associated, healthy and holding a DHCP lease -([#183](https://github.com/daqifi/daqifi-core/issues/183)). Nothing the library does with sockets -can fix that, because the frames never survive the radio. - -`MDnsDeviceFinder` browses for the `_daqifi._tcp.local.` service on the mDNS multicast group -(224.0.0.251:5353) instead. That is the one multicast group every prosumer router already reflects -across APs, SSIDs and VLANs, and it is the path that also survives WSL2 (whose NAT bridge drops -outbound broadcast) and client-isolated guest networks. The device advertises PTR + SRV + TXT + A -in a single reply; the finder parses the SRV port and A address, reads `sn` / `pn` / `fw` / -`friendly` out of the TXT record, and returns the same `IDeviceInfo` shape with -`ConnectionType.WiFi` — so it is a drop-in for anything already consuming discovery results. - -```csharp -using Daqifi.Core.Device.Discovery; - -using var finder = new AllTransportsDeviceFinder( - [new WiFiDeviceFinder(), new MDnsDeviceFinder(), new SerialDeviceFinder()]); - -var devices = await finder.DiscoverAsync(TimeSpan.FromSeconds(5)); -``` - -Running both network finders is additive, not redundant: a device on firmware without an mDNS -responder is still found by broadcast, and a device the broadcast cannot reach is still found by -mDNS. The same unit answering on both paths is deduplicated by the aggregator only if you pass an -`identitySelector` that collapses them (e.g. by serial number) — the default per-transport identity -prefers the MAC address, which the broadcast reply carries and the advertisement does not, so it -keeps them as two entries, both of which are genuinely connectable. - -Selecting on the serial number works because this finder reports it in the same form the other -finders do. The firmware publishes the board's 64-bit serial in TXT as 16 hex digits while the -protobuf path carries the same integer numerically, so the advertised value is converted to the -decimal representation the rest of the library uses (`0x7E2815916200E898` is `9090539562006014104`, -one board, not two). - -Practical notes: - +Because `AllTransportsDeviceFinder` is itself an `IDeviceFinder`, wrapping it in a +`ContinuousDeviceFinder` (below) gives deduplicated *continuous* discovery across every +transport for free. A transport finder that fails (e.g. WiFi discovery with no network) is +logged and skipped, so the other transports still return. Deduplication reuses +`ContinuousDeviceFinder`'s per-transport identity, so the same physical unit reachable over +both WiFi and USB appears as two connection options; pass a custom `identitySelector` (e.g. +by serial number) to collapse them. + +### Network Discovery Notes + +The short mDNS + broadcast recipe is in the README. These are the notes that page does not +keep: + +- **Why broadcast fails on a multi-AP home network.** `WiFiDeviceFinder` sends `DAQiFi?\r\n` + to the subnet broadcast address on UDP 30303. On a single-SSID, two-AP network with the + host on 5 GHz at one AP and the device on 2.4 GHz at the other, enough broadcast frames + are dropped crossing the AP boundary that ARP itself fails to resolve; discovery then + returns zero devices while the device is associated, healthy and holding a DHCP lease + ([#183](https://github.com/daqifi/daqifi-core/issues/183)). Nothing the library does with + sockets can fix that — the frames never survive the radio. `MDnsDeviceFinder` browses + `_daqifi._tcp.local.` on 224.0.0.251:5353 instead, which consumer routers already reflect + across APs and which also survives WSL2 NAT (broadcast is dropped) and client-isolated + guest networks. +- **Same board, two entries.** The default per-transport identity prefers the MAC address, + which the broadcast reply carries and the mDNS advertisement does not, so a unit answering + on both paths stays as two connectable entries. Pass `identitySelector: d => d.SerialNumber` + to collapse them. The firmware publishes the board's 64-bit serial in TXT as 16 hex digits + while the protobuf path carries the same integer numerically, so the advertised value is + converted to the decimal form the rest of the library uses (`0x7E2815916200E898` is + `9090539562006014104`, one board, not two). +- **The mDNS port is shared, not seized.** The finder binds UDP 5353 with `SO_REUSEADDR` + (plus `SO_REUSEPORT` on Linux/macOS, which BSD-derived stacks require) and joins the group + on every eligible NIC. Multicast datagrams are delivered to *every* socket joined to the + group, so running alongside the host's own mDNS daemon (mDNSResponder, Avahi) takes no + traffic from it. If the port cannot be shared at all, the pass returns empty rather than + throwing. +- **`LocalInterfaceAddress`** is resolved by matching the device's address against each local + interface's subnet, rather than read off the socket as the broadcast finder does — a + multicast listener has to be bound to the wildcard address to receive anything on Linux + and macOS. - **Firmware.** The device-side responder is [daqifi-nyquist-firmware#345](https://github.com/daqifi/daqifi-nyquist-firmware/issues/345). On firmware without it, this finder simply returns nothing — use the broadcast path. -- **The mDNS port is shared, not seized.** The finder binds UDP 5353 with `SO_REUSEADDR` (plus - `SO_REUSEPORT` on Linux/macOS, which BSD-derived stacks require) and joins the group on every - eligible NIC. Multicast datagrams are delivered to *every* socket joined to the group, so - running alongside the host's own mDNS daemon (mDNSResponder, Avahi) takes no traffic from it. - If the port cannot be shared at all, the pass returns empty rather than throwing. -- **Multicast-filtered networks.** Some corporate and hardened guest networks drop multicast - outright. Connect by IP address directly (`DaqifiDeviceFactory.ConnectTcpAsync`) when they do. -- **`LocalInterfaceAddress`** is resolved by matching the device's address against each local - interface's subnet, rather than read off the socket as the broadcast finder does — a multicast - listener has to be bound to the wildcard address to receive anything on Linux and macOS. ### Continuous Discovery (Live Device Set) @@ -1013,25 +963,19 @@ device.DisableChannel(ai0); // Turn everything off. device.DisableAllChannels(); -// Digital I/O: set direction and drive an output. -var dio1 = channels.First(c => c.Type == ChannelType.Digital && c.ChannelNumber == 1); -device.SetDioDirection(dio1, ChannelDirection.Output); -device.SetDioValue(dio1, true); // drive high - -// PWM on a capable channel (IDigitalChannel.IsPwmCapable). Duty is per channel; the -// frequency is device-wide because one hardware timer drives every PWM channel. -var pwm = channels.OfType().First(c => c.IsPwmCapable); -device.SetPwmDutyCycle(pwm, 25); // 1-100 % -device.SetPwmFrequency(1000); // 6-50000 Hz, shared by all PWM channels -device.SetPwmEnabled(pwm, true); // start; SetPwmEnabled(pwm, false) stops (pin goes high-impedance) - // Analog output (NQ3 only) — addressed by channel number; staged value is applied immediately. +// Check device.Supports(DeviceFeature.AnalogOutput) first — see Feature support below. device.SetAnalogOutput(0, 2.5); // DAC channel 0 to 2.5 V // Reboot the device (also disconnects, since the device drops its link while restarting). device.Reboot(); ``` +Digital I/O and PWM recipes live in the root README +([Digital output](../README.md#digital-output), [PWM output](../README.md#pwm-output)). +The `SetDio*` / `SetPwm*` members are on `IStreamingDevice` the same way the enable/disable +calls above are. + > Channel objects passed to the enable/disable and DIO methods must belong to the device's > `Channels` collection (so the internal state and bitmask stay in sync). Analog-output (DAC) > channels are not part of `Channels`, so `SetAnalogOutput` takes a channel number directly. @@ -1042,10 +986,44 @@ directly on `IStreamingDevice`: ```csharp await streamingDevice.EnableChannelsAsync(new[] { ai0, ai2 }, cancellationToken); -await streamingDevice.SetDioValueAsync(dio1, true, cancellationToken); +await streamingDevice.SetAnalogOutputAsync(0, 2.5, cancellationToken); await streamingDevice.RebootAsync(cancellationToken); ``` +### Feature support + +Some surfaces are board- or firmware-gated. Branch on `device.Supports(DeviceFeature)` — +do not compare firmware strings yourself. Calling the gated API anyway throws +`FeatureNotSupportedException` (feature, required version, reported version, board). + +```csharp +await using var device = await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760); + +// Analog output is Nyquist 3 hardware only. +if (device.Supports(DeviceFeature.AnalogOutput)) + device.SetAnalogOutput(0, 2.5); + +// SD list/get/delete over WiFi needs firmware ≥ 3.7.0 plus SD + WiFi hardware. +// Over USB the same operations are available on all SD-capable firmware and are not gated. +if (device.Supports(DeviceFeature.SdFileTransferOverWifi)) +{ + var files = await device.GetSdCardFilesAsync(); +} + +try +{ + await device.GetSdCardFilesAsync(); +} +catch (FeatureNotSupportedException ex) +{ + Console.WriteLine($"{ex.Feature}: needs {ex.RequiredVersion}, device reports {ex.ActualVersion} ({ex.Board})"); +} +``` + +`DaqifiDeviceFactory` returns `DaqifiStreamingDevice`, which inherits `Supports` from +`DaqifiDevice`. The requirement table and `-113` backstop live in +[ADR 0001](adr/0001-firmware-feature-gating.md) — this is the call-site pattern only. + ## SCPI Commands Use `ScpiMessageProducer` for device commands: @@ -1310,19 +1288,3 @@ if (device.DecodeFailureCount > 0) `ErrorOccurred` is raised on a background thread, so handlers should do the minimum and push real work elsewhere. A handler that throws is caught and ignored — it can never disturb reading or streaming. - -## Features - -- **Simple Factory API**: Single-call connection with `DaqifiDeviceFactory` -- **Multi-Device Registry**: `DaqifiDeviceRegistry` tracks the live device set and detects the same - unit connected over two transports -- **Clean Abstraction**: Hardware details hidden behind well-defined interfaces -- **Event-Driven**: Status changes and messages handled via events -- **Observable Failures**: Background read and decode errors surface on `ErrorOccurred` instead of - failing silently -- **Opt-in Auto-Reconnect**: A dropped connection can rebuild itself — transport, initialization, - channel configuration and stream — with no consumer code -- **Type Safety**: Generic message types provide compile-time safety -- **Retry Support**: Built-in connection retry with exponential backoff -- **Thread-Safe Sending**: Background message queue for thread-safe command sending -- **Cross-Platform**: Compatible with .NET 9.0 and .NET 10.0 diff --git a/docs/adr/0001-firmware-feature-gating.md b/docs/adr/0001-firmware-feature-gating.md index 6f91c474..9db8ca8d 100644 --- a/docs/adr/0001-firmware-feature-gating.md +++ b/docs/adr/0001-firmware-feature-gating.md @@ -144,9 +144,11 @@ logging. **Consequence — this collapses most of the gating problem:** -- Every command daqifi-core issues today exists on all supported firmware → **no version - table entries are needed yet**. Don't build the table until we consume a post-v3.5.0 - command. +- Every command daqifi-core issued at decision time existed on all supported firmware → + **no version table entries were needed yet**. Don't build the table until we consume a + post-v3.5.0 command. *(Amendment: that trigger has landed. `SdFileTransferOverWifi` + (≥ v3.7.0) shipped the table in [#256](https://github.com/daqifi/daqifi-core/issues/256); + the "don't build until" rule is unchanged — see the implementation note under Decision 2.)* - The **version-selected-command** mode (send the old name on old firmware) is **unnecessary and out of scope** — there is no supported firmware that needs the pre-rename names. Core #253's unconditional `SD:FILE` is correct as-is. @@ -195,6 +197,8 @@ therefore be snapshotted before/without the firmware version and silently go sta ```csharp namespace Daqifi.Core.Device; +// Historical sketch (2026-06-19). The shipped enum also includes SdFileTransferOverWifi +// (the first post-floor member; the table was built for it — see the #256 note below). public enum DeviceFeature { AnalogOutput, // SOURce:VOLTage:LEVel / CONF:DAC — board gate: NQ3 only @@ -203,7 +207,8 @@ public enum DeviceFeature // supported firmware: SdStorageQuery, // SYSTem:STORage:SD:SPACe? (fw v3.4.6b1) CapabilityDocument, // CONFigure:CAPabilities:JSON? (fw v3.5.0) - // … add post-v3.5.0 commands here as we consume them; that is when the table is built. + SdFileTransferOverWifi, // SYSTem:STORage:SD:LIST? / :GET / :DELete over TCP (fw ≥ v3.7.0) + // Further post-v3.5.0 commands are added here as we consume them. } // Device/FeatureNotSupportedException.cs — the typed backstop. @@ -325,7 +330,9 @@ predates firmware v3.5.0 removing the Type-2 muxed scan-rate cap (firmware #528) The decision is: **a v3.5.0 floor + board-derived capability (both live) + the `-113` typed backstop (always correct), with the version table and #327 reader deferred until they earn -their place.** +their place.** Both have now earned it — see the implementation notes under Decision 2 +([#256](https://github.com/daqifi/daqifi-core/issues/256) table, +[#390](https://github.com/daqifi/daqifi-core/issues/390) reader). ## Consequences @@ -334,8 +341,8 @@ their place.** code is the floor constant + the `-113` → `FeatureNotSupportedException` backstop. - Today's generic `SdCardOperationException` for old firmware becomes a clear, typed `FeatureNotSupportedException` carrying required-vs-actual version. -- The seam is stable, so the deferred table and #327 reader slot in later without touching - consumers. +- The seam is stable, so the table (#256) and capability-document reader (#390) slotted in + later without touching consumers — see the implementation notes under Decision 2. **Negative / costs** - A floor is a support commitment: pre-v3.5.0 devices get best-effort behavior and typed From cd17b27e980a18c89185a743373fd86cdf9713f3 Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Sun, 20 Sep 2026 15:17:30 -1000 Subject: [PATCH 2/4] docs: rebase on #740 and tighten the feature-support example Review follow-ups on this PR: - Rebased onto #740, which is reviewed and edits the same two files. The only textual conflict was the Quick Start block both PRs rewrote; #740's corrected snippet is now identical to the README's, so this takes the link and renames the section "Start here" rather than shipping a third copy to drift. #740's CI-OS fix and its ADR follow-up/table edits are untouched. - The feature-support sample called GetSdCardFilesAsync twice, guarded and then unguarded, which read as a copy-paste. Split into the two real choices: check first, or catch and report. Braces on the single-line if. Co-Authored-By: Claude Opus 5 --- docs/DEVICE_INTERFACES.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/DEVICE_INTERFACES.md b/docs/DEVICE_INTERFACES.md index 463d9d41..e7136f1b 100644 --- a/docs/DEVICE_INTERFACES.md +++ b/docs/DEVICE_INTERFACES.md @@ -999,23 +999,31 @@ do not compare firmware strings yourself. Calling the gated API anyway throws ```csharp await using var device = await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760); -// Analog output is Nyquist 3 hardware only. +// Ask before you offer it: analog output is Nyquist 3 hardware only. if (device.Supports(DeviceFeature.AnalogOutput)) +{ device.SetAnalogOutput(0, 2.5); +} -// SD list/get/delete over WiFi needs firmware ≥ 3.7.0 plus SD + WiFi hardware. -// Over USB the same operations are available on all SD-capable firmware and are not gated. +// SD list/get/delete over this WiFi connection needs firmware >= 3.7.0 plus SD and WiFi +// hardware. Over USB the same operations run on any SD-capable firmware and are not gated. if (device.Supports(DeviceFeature.SdFileTransferOverWifi)) { var files = await device.GetSdCardFilesAsync(); } +``` +Or skip the check and let the call tell you — the exception carries everything needed to say +why, which is usually what a UI wants to show: + +```csharp try { - await device.GetSdCardFilesAsync(); + var files = await device.GetSdCardFilesAsync(); } catch (FeatureNotSupportedException ex) { + // e.g. "SdFileTransferOverWifi: needs 3.7.0, device reports 3.6.1 (Nyquist1)" Console.WriteLine($"{ex.Feature}: needs {ex.RequiredVersion}, device reports {ex.ActualVersion} ({ex.Board})"); } ``` From a35a30adab9ff8c431cb4a4952bb728523249307 Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Sun, 27 Sep 2026 10:14:42 -0600 Subject: [PATCH 3/4] docs: make the feature-support SD check transport-aware Supports(SdFileTransferOverWifi) answers for the device, not the link, so the sample's unconditional check would hide SD files a USB connection can read on pre-3.7.0 firmware (Qodo). The check is now IsUsbConnection || Supports(...), the same predicate EnsureSdFileTransferSupportedOnTransport applies. The section also claimed every gated call throws FeatureNotSupportedException. Only the SD calls enforce their gate; SetAnalogOutput sends regardless and a non-NQ3 board rejects it with no exception, so Supports is the only guard there. Say so, and note that RequiredVersion is null for a board/hardware shortfall. Restore the mDNS reply-parsing detail (PTR/SRV/TXT/A, the TXT keys, ConnectionType.WiFi) that the dedupe dropped without a README copy, and list :SPACe? under SdFileTransferOverWifi in the ADR sketch to match the enum. Co-Authored-By: Claude Opus 5.5 --- docs/DEVICE_INTERFACES.md | 35 ++++++++++++++++++------ docs/adr/0001-firmware-feature-gating.md | 2 +- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/docs/DEVICE_INTERFACES.md b/docs/DEVICE_INTERFACES.md index e7136f1b..a7c743bd 100644 --- a/docs/DEVICE_INTERFACES.md +++ b/docs/DEVICE_INTERFACES.md @@ -251,6 +251,9 @@ keep: `_daqifi._tcp.local.` on 224.0.0.251:5353 instead, which consumer routers already reflect across APs and which also survives WSL2 NAT (broadcast is dropped) and client-isolated guest networks. +- **What the mDNS finder reads.** The device answers with PTR + SRV + TXT + A in one reply; + the finder takes the port from SRV, the address from A, and `sn` / `pn` / `fw` / + `friendly` from TXT, and reports the result as `ConnectionType.WiFi`. - **Same board, two entries.** The default per-transport identity prefers the MAC address, which the broadcast reply carries and the mDNS advertisement does not, so a unit answering on both paths stays as two connectable entries. Pass `identitySelector: d => d.SerialNumber` @@ -993,8 +996,16 @@ await streamingDevice.RebootAsync(cancellationToken); ### Feature support Some surfaces are board- or firmware-gated. Branch on `device.Supports(DeviceFeature)` — -do not compare firmware strings yourself. Calling the gated API anyway throws -`FeatureNotSupportedException` (feature, required version, reported version, board). +do not compare firmware strings yourself. `Supports` answers for the device, not for the +link it is on, and not every gated call enforces its own gate: + +- **SD list / download / delete and the storage-space query** enforce theirs, and only on a + WiFi/TCP link: there they throw `FeatureNotSupportedException` (feature, required version, + reported version, board) unless the device supports `SdFileTransferOverWifi`. Over USB + that gate does not apply — the SD card is reachable on any supported firmware. +- **`SetAnalogOutput`** does not: it sends the command whatever the board, and a board other + than Nyquist 3 rejects it on the device without an exception reaching you. Checking + `Supports(DeviceFeature.AnalogOutput)` is the only guard. ```csharp await using var device = await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760); @@ -1005,16 +1016,17 @@ if (device.Supports(DeviceFeature.AnalogOutput)) device.SetAnalogOutput(0, 2.5); } -// SD list/get/delete over this WiFi connection needs firmware >= 3.7.0 plus SD and WiFi -// hardware. Over USB the same operations run on any SD-capable firmware and are not gated. -if (device.Supports(DeviceFeature.SdFileTransferOverWifi)) +// SdFileTransferOverWifi (firmware >= 3.7.0 plus SD and WiFi hardware) is the gate only on a +// network link. Don't ask it over USB: it would say no for a 3.6.x device whose SD card USB +// reads fine. +if (device.IsUsbConnection || device.Supports(DeviceFeature.SdFileTransferOverWifi)) { var files = await device.GetSdCardFilesAsync(); } ``` -Or skip the check and let the call tell you — the exception carries everything needed to say -why, which is usually what a UI wants to show: +For the SD calls you can also skip the check and let the call tell you — the exception +carries everything needed to say why, which is usually what a UI wants to show: ```csharp try @@ -1028,9 +1040,14 @@ catch (FeatureNotSupportedException ex) } ``` +`RequiredVersion` is set only when the firmware is what falls short; a board or hardware +shortfall leaves it `null`, since no upgrade would help. + `DaqifiDeviceFactory` returns `DaqifiStreamingDevice`, which inherits `Supports` from -`DaqifiDevice`. The requirement table and `-113` backstop live in -[ADR 0001](adr/0001-firmware-feature-gating.md) — this is the call-site pattern only. +`DaqifiDevice` and adds `IsUsbConnection`. Neither is declared on `IStreamingDevice`, so code +holding only the interface needs the concrete type. The requirement table and `-113` +backstop live in [ADR 0001](adr/0001-firmware-feature-gating.md) — this is the call-site +pattern only. ## SCPI Commands diff --git a/docs/adr/0001-firmware-feature-gating.md b/docs/adr/0001-firmware-feature-gating.md index 9db8ca8d..c1f4aac0 100644 --- a/docs/adr/0001-firmware-feature-gating.md +++ b/docs/adr/0001-firmware-feature-gating.md @@ -207,7 +207,7 @@ public enum DeviceFeature // supported firmware: SdStorageQuery, // SYSTem:STORage:SD:SPACe? (fw v3.4.6b1) CapabilityDocument, // CONFigure:CAPabilities:JSON? (fw v3.5.0) - SdFileTransferOverWifi, // SYSTem:STORage:SD:LIST? / :GET / :DELete over TCP (fw ≥ v3.7.0) + SdFileTransferOverWifi, // SYSTem:STORage:SD:LIST? / :GET / :DELete / :SPACe? over TCP (fw ≥ v3.7.0) // Further post-v3.5.0 commands are added here as we consume them. } From 5b5a31bf08a243336b8fda0e4485bae9d1f71b71 Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Sun, 27 Sep 2026 10:20:13 -0600 Subject: [PATCH 4/4] docs: guard analog output on an identified board, branch on RequiredVersion Supports skips board and hardware requirements while DeviceType is Unknown, so Supports(AnalogOutput) alone answers true for a board that has not reported its part number, and SetAnalogOutput has no gate of its own behind it. The sample now also requires an identified board, and the text explains why (Qodo). The catch sample printed "needs {RequiredVersion}" unconditionally, which renders "needs ," for a board/hardware shortfall where RequiredVersion is null. It now branches on it (Qodo). Co-Authored-By: Claude Opus 5.5 --- docs/DEVICE_INTERFACES.md | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/docs/DEVICE_INTERFACES.md b/docs/DEVICE_INTERFACES.md index a7c743bd..61d56f6e 100644 --- a/docs/DEVICE_INTERFACES.md +++ b/docs/DEVICE_INTERFACES.md @@ -1004,14 +1004,22 @@ link it is on, and not every gated call enforces its own gate: reported version, board) unless the device supports `SdFileTransferOverWifi`. Over USB that gate does not apply — the SD card is reachable on any supported firmware. - **`SetAnalogOutput`** does not: it sends the command whatever the board, and a board other - than Nyquist 3 rejects it on the device without an exception reaching you. Checking - `Supports(DeviceFeature.AnalogOutput)` is the only guard. + than Nyquist 3 rejects it on the device without an exception reaching you. The check is + yours to make. + +One more subtlety: board and hardware requirements are only evaluated once the board is +identified. While `device.Metadata.DeviceType` is still `Unknown` (no part number reported +yet), `Supports` does not hold that against the device — the firmware-version requirement +still applies, but a board-only gate such as `AnalogOutput` answers `true`. Where nothing +downstream will refuse the call, require an identified board too. ```csharp await using var device = await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760); -// Ask before you offer it: analog output is Nyquist 3 hardware only. -if (device.Supports(DeviceFeature.AnalogOutput)) +// Ask before you offer it: analog output is Nyquist 3 hardware only, and nothing after this +// check will stop the command, so don't accept an unidentified board. +if (device.Metadata.DeviceType != DeviceType.Unknown + && device.Supports(DeviceFeature.AnalogOutput)) { device.SetAnalogOutput(0, 2.5); } @@ -1035,14 +1043,15 @@ try } catch (FeatureNotSupportedException ex) { - // e.g. "SdFileTransferOverWifi: needs 3.7.0, device reports 3.6.1 (Nyquist1)" - Console.WriteLine($"{ex.Feature}: needs {ex.RequiredVersion}, device reports {ex.ActualVersion} ({ex.Board})"); + // RequiredVersion is set only when the firmware is what falls short. A board or hardware + // shortfall leaves it null, since no upgrade would help. (ex.Message already covers both.) + var why = ex.RequiredVersion is { } required + ? $"needs firmware {required}, device reports {ex.ActualVersion}" // e.g. 3.7.0 vs 3.6.1 + : $"not available on this hardware ({ex.Board})"; + Console.WriteLine($"{ex.Feature}: {why}"); } ``` -`RequiredVersion` is set only when the firmware is what falls short; a board or hardware -shortfall leaves it `null`, since no upgrade would help. - `DaqifiDeviceFactory` returns `DaqifiStreamingDevice`, which inherits `Supports` from `DaqifiDevice` and adds `IsUsbConnection`. Neither is declared on `IStreamingDevice`, so code holding only the interface needs the concrete type. The requirement table and `-113`